Backup support for quotes & quoted attachments

This commit is contained in:
trevor-signal 2024-06-10 14:44:15 -04:00 committed by GitHub
parent 0f2b71b4a6
commit e0dc4c412d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 653 additions and 289 deletions

View file

@ -24,7 +24,10 @@ import { getClassNamesFor } from '../../util/getClassNamesFor';
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import type { AnyPaymentEvent } from '../../types/Payment';
import { PaymentEventKind } from '../../types/Payment';
import { getPaymentEventNotificationText } from '../../messages/helpers';
import {
getPaymentEventNotificationText,
shouldTryToCopyFromQuotedMessage,
} from '../../messages/helpers';
import { RenderLocation } from './MessageTextRenderer';
import type { QuotedAttachmentType } from '../../model-types';
@ -165,10 +168,19 @@ export function Quote(props: Props): JSX.Element | null {
const getClassName = getClassNamesFor('module-quote', moduleClassName);
useEffect(() => {
if (referencedMessageNotFound) {
if (
shouldTryToCopyFromQuotedMessage({
referencedMessageNotFound,
quoteAttachment: rawAttachment,
})
) {
doubleCheckMissingQuoteReference?.();
}
}, [referencedMessageNotFound, doubleCheckMissingQuoteReference]);
}, [
referencedMessageNotFound,
rawAttachment,
doubleCheckMissingQuoteReference,
]);
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
// This is important to ensure that using this quote to navigate to the referenced

View file

@ -789,17 +789,17 @@ async function uploadMessageQuote({
loadedQuote.attachments.map(
attachment => async (): Promise<OutgoingQuoteAttachmentType> => {
const { thumbnail } = attachment;
if (!thumbnail) {
if (!thumbnail || !thumbnail.data) {
return {
contentType: attachment.contentType,
fileName: attachment.fileName,
};
}
const { data } = thumbnail;
strictAssert(data, 'data must be loaded into thumbnail');
const uploaded = await uploadAttachment({ ...thumbnail, data });
const uploaded = await uploadAttachment({
...thumbnail,
data: thumbnail.data,
});
return {
contentType: attachment.contentType,

197
ts/messages/copyQuote.ts Normal file
View file

@ -0,0 +1,197 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import * as log from '../logging/log';
import type { QuotedMessageType } from '../model-types';
import type { MessageModel } from '../models/messages';
import { SignalService } from '../protobuf';
import { isGiftBadge, isTapToView } from '../state/selectors/message';
import type { ProcessedQuote } from '../textsecure/Types';
import { IMAGE_JPEG } from '../types/MIME';
import { strictAssert } from '../util/assert';
import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { find } from '../util/iterables';
import { isQuoteAMatch, messageHasPaymentEvent } from './helpers';
import * as Errors from '../types/errors';
import { isDownloadable } from '../types/Attachment';
export const copyFromQuotedMessage = async (
quote: ProcessedQuote,
conversationId: string
): Promise<QuotedMessageType> => {
const { id } = quote;
strictAssert(id, 'Quote must have an id');
const result: QuotedMessageType = {
...omit(quote, 'type'),
id,
attachments: quote.attachments.slice(),
bodyRanges: quote.bodyRanges?.slice(),
// Just placeholder values for the fields
referencedMessageNotFound: false,
isGiftBadge: quote.type === SignalService.DataMessage.Quote.Type.GIFT_BADGE,
isViewOnce: false,
messageId: '',
};
const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt(id);
const matchingMessage = find(inMemoryMessages, item =>
isQuoteAMatch(item.attributes, conversationId, result)
);
let queryMessage: undefined | MessageModel;
if (matchingMessage) {
queryMessage = matchingMessage;
} else {
log.info('copyFromQuotedMessage: db lookup needed', id);
const messages = await window.Signal.Data.getMessagesBySentAt(id);
const found = messages.find(item =>
isQuoteAMatch(item, conversationId, result)
);
if (!found) {
result.referencedMessageNotFound = true;
return result;
}
queryMessage = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'copyFromQuotedMessage'
);
}
if (queryMessage) {
await copyQuoteContentFromOriginal(queryMessage, result);
}
return result;
};
export const copyQuoteContentFromOriginal = async (
originalMessage: MessageModel,
quote: QuotedMessageType
): Promise<void> => {
const { attachments } = quote;
const firstAttachment = attachments ? attachments[0] : undefined;
if (messageHasPaymentEvent(originalMessage.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.payment = originalMessage.get('payment');
}
if (isTapToView(originalMessage.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.text = undefined;
// eslint-disable-next-line no-param-reassign
quote.attachments = [
{
contentType: IMAGE_JPEG,
},
];
// eslint-disable-next-line no-param-reassign
quote.isViewOnce = true;
return;
}
const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes);
if (isMessageAGiftBadge !== quote.isGiftBadge) {
log.warn(
`copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}`
);
// eslint-disable-next-line no-param-reassign
quote.isGiftBadge = isMessageAGiftBadge;
}
if (isMessageAGiftBadge) {
// eslint-disable-next-line no-param-reassign
quote.text = undefined;
// eslint-disable-next-line no-param-reassign
quote.attachments = [];
return;
}
// eslint-disable-next-line no-param-reassign
quote.isViewOnce = false;
// eslint-disable-next-line no-param-reassign
quote.text = getQuoteBodyText(originalMessage.attributes, quote.id);
// eslint-disable-next-line no-param-reassign
quote.bodyRanges = originalMessage.attributes.bodyRanges;
if (!firstAttachment || !firstAttachment.contentType) {
return;
}
try {
const schemaVersion = originalMessage.get('schemaVersion');
if (
schemaVersion &&
schemaVersion < window.Signal.Types.Message.VERSION_NEEDED_FOR_DISPLAY
) {
const upgradedMessage =
await window.Signal.Migrations.upgradeMessageSchema(
originalMessage.attributes
);
originalMessage.set(upgradedMessage);
await window.Signal.Data.saveMessage(upgradedMessage, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
}
} catch (error) {
log.error(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return;
}
const queryAttachments = originalMessage.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
copied: true,
};
} else if (!firstAttachment.thumbnail || !isDownloadable(queryFirst)) {
firstAttachment.contentType = queryFirst.contentType;
firstAttachment.fileName = queryFirst.fileName;
firstAttachment.thumbnail = undefined;
} else {
// there is a thumbnail, but the original message attachment has not been
// downloaded yet, so we leave the quote attachment as is for now
}
}
const queryPreview = originalMessage.get('preview') || [];
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
copied: true,
};
}
}
const sticker = originalMessage.get('sticker');
if (sticker && sticker.data && sticker.data.path) {
firstAttachment.thumbnail = {
...sticker.data,
copied: true,
};
}
};

View file

@ -6,6 +6,7 @@ import type { ConversationModel } from '../models/conversations';
import type {
CustomError,
MessageAttributesType,
QuotedAttachmentType,
QuotedMessageType,
} from '../model-types.d';
import type { ServiceIdString } from '../types/ServiceId';
@ -136,6 +137,31 @@ export function isQuoteAMatch(
);
}
export const shouldTryToCopyFromQuotedMessage = ({
referencedMessageNotFound,
quoteAttachment,
}: {
referencedMessageNotFound: boolean;
quoteAttachment: QuotedAttachmentType | undefined;
}): boolean => {
// If we've tried and can't find the message, try again.
if (referencedMessageNotFound === true) {
return true;
}
// Otherwise, try again in case we have not yet copied over the thumbnail from the
// original attachment (maybe it had not been downloaded when we first checked)
if (!quoteAttachment?.thumbnail) {
return false;
}
if (quoteAttachment.thumbnail.copied === true) {
return false;
}
return true;
};
export function getAuthorId(
message: Pick<MessageAttributesType, 'type' | 'source' | 'sourceServiceId'>
): string | undefined {

View file

@ -17,7 +17,6 @@ import type {
CustomError,
MessageAttributesType,
MessageReactionType,
QuotedMessageType,
} from '../model-types.d';
import { filter, find, map, repeat, zipObject } from '../util/iterables';
import * as GoogleChrome from '../util/GoogleChrome';
@ -31,7 +30,6 @@ import { drop } from '../util/drop';
import type { ConversationModel } from './conversations';
import type {
ProcessedDataMessage,
ProcessedQuote,
ProcessedUnidentifiedDeliveryStatus,
CallbackResultType,
} from '../textsecure/Types.d';
@ -45,7 +43,7 @@ import { normalizeServiceId } from '../types/ServiceId';
import { isAciString } from '../util/isAciString';
import * as reactionUtil from '../reactions/util';
import * as Errors from '../types/errors';
import type { AttachmentType } from '../types/Attachment';
import { type AttachmentType } from '../types/Attachment';
import * as MIME from '../types/MIME';
import { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStateByConversationId } from '../messages/MessageSendState';
@ -119,6 +117,7 @@ import {
messageHasPaymentEvent,
isQuoteAMatch,
getAuthor,
shouldTryToCopyFromQuotedMessage,
} from '../messages/helpers';
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
import { getMessageIdForLogging } from '../util/idForLogging';
@ -136,7 +135,6 @@ import {
shouldUseAttachmentDownloadQueue,
} from '../util/attachmentDownloadQueue';
import dataInterface from '../sql/Client';
import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
import type { RawBodyRange } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
@ -154,6 +152,10 @@ import {
} from '../util/editHelpers';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
import {
copyFromQuotedMessage,
copyQuoteContentFromOriginal,
} from '../messages/copyQuote';
/* eslint-disable more/no-then */
@ -448,10 +450,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Is the quote really without a reference? Check with our in memory store
// first to make sure it's not there.
if (referencedMessageNotFound && contact) {
log.info(
`doubleCheckMissingQuoteReference/${logId}: Verifying reference to ${sentAt}`
);
if (
contact &&
shouldTryToCopyFromQuotedMessage({
referencedMessageNotFound,
quoteAttachment: quote.attachments.at(0),
})
) {
const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt(
Number(sentAt)
);
@ -492,7 +497,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.`
);
await this.copyQuoteContentFromOriginal(matchingMessage, quote);
await copyQuoteContentFromOriginal(matchingMessage, quote);
this.set({
quote: {
...quote,
@ -1392,190 +1397,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
});
}
async copyFromQuotedMessage(
quote: ProcessedQuote | undefined,
conversationId: string
): Promise<QuotedMessageType | undefined> {
if (!quote) {
return undefined;
}
const { id } = quote;
strictAssert(id, 'Quote must have an id');
const result: QuotedMessageType = {
...quote,
id,
attachments: quote.attachments.slice(),
bodyRanges: quote.bodyRanges?.slice(),
// Just placeholder values for the fields
referencedMessageNotFound: false,
isGiftBadge: quote.type === Proto.DataMessage.Quote.Type.GIFT_BADGE,
isViewOnce: false,
messageId: '',
};
const inMemoryMessages =
window.MessageCache.__DEPRECATED$filterBySentAt(id);
const matchingMessage = find(inMemoryMessages, item =>
isQuoteAMatch(item.attributes, conversationId, result)
);
let queryMessage: undefined | MessageModel;
if (matchingMessage) {
queryMessage = matchingMessage;
} else {
log.info('copyFromQuotedMessage: db lookup needed', id);
const messages = await window.Signal.Data.getMessagesBySentAt(id);
const found = messages.find(item =>
isQuoteAMatch(item, conversationId, result)
);
if (!found) {
result.referencedMessageNotFound = true;
return result;
}
queryMessage = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'copyFromQuotedMessage'
);
}
if (queryMessage) {
await this.copyQuoteContentFromOriginal(queryMessage, result);
}
return result;
}
async copyQuoteContentFromOriginal(
originalMessage: MessageModel,
quote: QuotedMessageType
): Promise<void> {
const { attachments } = quote;
const firstAttachment = attachments ? attachments[0] : undefined;
if (messageHasPaymentEvent(originalMessage.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.payment = originalMessage.get('payment');
}
if (isTapToView(originalMessage.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.text = undefined;
// eslint-disable-next-line no-param-reassign
quote.attachments = [
{
contentType: MIME.IMAGE_JPEG,
},
];
// eslint-disable-next-line no-param-reassign
quote.isViewOnce = true;
return;
}
const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes);
if (isMessageAGiftBadge !== quote.isGiftBadge) {
log.warn(
`copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}`
);
// eslint-disable-next-line no-param-reassign
quote.isGiftBadge = isMessageAGiftBadge;
}
if (isMessageAGiftBadge) {
// eslint-disable-next-line no-param-reassign
quote.text = undefined;
// eslint-disable-next-line no-param-reassign
quote.attachments = [];
return;
}
// eslint-disable-next-line no-param-reassign
quote.isViewOnce = false;
// eslint-disable-next-line no-param-reassign
quote.text = getQuoteBodyText(originalMessage.attributes, quote.id);
// eslint-disable-next-line no-param-reassign
quote.bodyRanges = originalMessage.attributes.bodyRanges;
if (firstAttachment) {
firstAttachment.thumbnail = undefined;
}
if (!firstAttachment || !firstAttachment.contentType) {
return;
}
try {
const schemaVersion = originalMessage.get('schemaVersion');
if (
schemaVersion &&
schemaVersion < TypedMessage.VERSION_NEEDED_FOR_DISPLAY
) {
const upgradedMessage = await upgradeMessageSchema(
originalMessage.attributes
);
originalMessage.set(upgradedMessage);
await window.Signal.Data.saveMessage(upgradedMessage, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
}
} catch (error) {
log.error(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return;
}
const queryAttachments = originalMessage.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
copied: true,
};
} else {
firstAttachment.contentType = queryFirst.contentType;
firstAttachment.fileName = queryFirst.fileName;
firstAttachment.thumbnail = undefined;
}
}
const queryPreview = originalMessage.get('preview') || [];
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
copied: true,
};
}
}
const sticker = originalMessage.get('sticker');
if (sticker && sticker.data && sticker.data.path) {
firstAttachment.thumbnail = {
...sticker.data,
copied: true,
};
}
}
async handleDataMessage(
initialMessage: ProcessedDataMessage,
confirm: () => void,
@ -1911,7 +1732,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
const [quote, storyQuotes] = await Promise.all([
this.copyFromQuotedMessage(initialMessage.quote, conversation.id),
initialMessage.quote
? copyFromQuotedMessage(initialMessage.quote, conversation.id)
: undefined,
findStoryMessages(conversation.id, storyContext),
]);

View file

@ -1627,15 +1627,19 @@ export class BackupExportStream extends Readable {
return groupUpdate;
}
private async toQuote(
quote?: QuotedMessageType
): Promise<Backups.IQuote | null> {
private async toQuote({
quote,
backupLevel,
messageReceivedAt,
}: {
quote?: QuotedMessageType;
backupLevel: BackupLevel;
messageReceivedAt: number;
}): Promise<Backups.IQuote | null> {
if (!quote) {
return null;
}
const quotedMessage = await Data.getMessageById(quote.messageId);
let authorId: Long;
if (quote.authorAci) {
authorId = this.getOrPushPrivateRecipient({
@ -1653,19 +1657,28 @@ export class BackupExportStream extends Readable {
}
return {
targetSentTimestamp:
quotedMessage && !quote.referencedMessageNotFound
? Long.fromNumber(quotedMessage.sent_at)
: null,
targetSentTimestamp: Long.fromNumber(quote.id),
authorId,
text: quote.text,
attachments: quote.attachments.map((attachment: QuotedAttachmentType) => {
return {
contentType: attachment.contentType,
fileName: attachment.fileName,
thumbnail: null,
};
}),
attachments: await Promise.all(
quote.attachments.map(
async (
attachment: QuotedAttachmentType
): Promise<Backups.Quote.IQuotedAttachment> => {
return {
contentType: attachment.contentType,
fileName: attachment.fileName,
thumbnail: attachment.thumbnail
? await this.processMessageAttachment({
attachment: attachment.thumbnail,
backupLevel,
messageReceivedAt,
})
: undefined,
};
}
)
),
bodyRanges: quote.bodyRanges?.map(range => this.toBodyRange(range)),
type: quote.isGiftBadge
? Backups.Quote.Type.GIFTBADGE
@ -1880,7 +1893,11 @@ export class BackupExportStream extends Readable {
const includeText = !isVoiceMessage;
return {
quote: await this.toQuote(message.quote),
quote: await this.toQuote({
quote: message.quote,
backupLevel,
messageReceivedAt: message.received_at,
}),
attachments: message.attachments
? await Promise.all(
message.attachments.map(attachment => {

View file

@ -29,6 +29,7 @@ import type {
MessageAttributesType,
MessageReactionType,
EditHistoryType,
QuotedMessageType,
} from '../../model-types.d';
import { assertDev, strictAssert } from '../../util/assert';
import { getTimestampFromLong } from '../../util/timestampLongUtils';
@ -62,6 +63,9 @@ import {
convertBackupMessageAttachmentToAttachment,
convertFilePointerToAttachment,
} from './util/filePointers';
import { filterAndClean } from '../../types/BodyRange';
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
import { copyFromQuotedMessage } from '../../messages/copyQuote';
const MAX_CONCURRENCY = 10;
@ -111,6 +115,8 @@ async function processMessagesBatch(
id: ids[index],
};
window.MessageCache.__DEPRECATED$unregister(attributes.id);
const { editHistory } = attributes;
if (editHistory?.length) {
@ -388,14 +394,28 @@ export class BackupImportStream extends Writable {
}
private saveConversation(attributes: ConversationAttributesType): void {
// add the conversation into memory without saving it to DB (that will happen in
// batcher); if we didn't do this, when we register messages to MessageCache, it would
// automatically create (and save to DB) a duplicate conversation which would have to
// be later merged
window.ConversationController.dangerouslyCreateAndAdd(attributes);
this.conversationOpBatcher.add({ isUpdate: false, attributes });
}
private updateConversation(attributes: ConversationAttributesType): void {
const existing = window.ConversationController.get(attributes.id);
if (existing) {
existing.set(attributes);
}
this.conversationOpBatcher.add({ isUpdate: true, attributes });
}
private saveMessage(attributes: MessageAttributesType): void {
window.MessageCache.__DEPRECATED$register(
attributes.id,
attributes,
'import.saveMessage'
);
this.saveMessageBatcher.add(attributes);
}
@ -802,7 +822,7 @@ export class BackupImportStream extends Writable {
attributes = {
...attributes,
...this.fromStandardMessage(item.standardMessage),
...(await this.fromStandardMessage(item.standardMessage, chatConvo.id)),
};
} else {
const result = await this.fromNonBubbleChatItem(item, {
@ -838,7 +858,7 @@ export class BackupImportStream extends Writable {
'Only standard message can have revisions'
);
const history = this.fromRevisions(attributes, item.revisions);
const history = await this.fromRevisions(attributes, item.revisions);
attributes.editHistory = history;
// Update timestamps on the parent message
@ -971,10 +991,10 @@ export class BackupImportStream extends Writable {
return { patch: {} };
}
private fromStandardMessage(
data: Backups.IStandardMessage
): Partial<MessageAttributesType> {
// TODO (DESKTOP-6964): Quote, link preview
private async fromStandardMessage(
data: Backups.IStandardMessage,
conversationId: string
): Promise<Partial<MessageAttributesType>> {
return {
body: data.text?.body || undefined,
attachments: data.attachments?.length
@ -998,38 +1018,46 @@ export class BackupImportStream extends Writable {
})
: undefined,
reactions: this.fromReactions(data.reactions),
quote: data.quote
? await this.fromQuote(data.quote, conversationId)
: undefined,
};
}
private fromRevisions(
private async fromRevisions(
mainMessage: MessageAttributesType,
revisions: ReadonlyArray<Backups.IChatItem>
): Array<EditHistoryType> {
const result = revisions
.map(rev => {
strictAssert(
rev.standardMessage,
'Edit history has non-standard messages'
);
): Promise<Array<EditHistoryType>> {
const result = await Promise.all(
revisions
.map(async rev => {
strictAssert(
rev.standardMessage,
'Edit history has non-standard messages'
);
const timestamp = getTimestampFromLong(rev.dateSent);
const timestamp = getTimestampFromLong(rev.dateSent);
const {
// eslint-disable-next-line camelcase
patch: { sendStateByConversationId, received_at_ms },
} = this.fromDirectionDetails(rev, timestamp);
const {
// eslint-disable-next-line camelcase
patch: { sendStateByConversationId, received_at_ms },
} = this.fromDirectionDetails(rev, timestamp);
return {
...this.fromStandardMessage(rev.standardMessage),
timestamp,
received_at: incrementMessageCounter(),
sendStateByConversationId,
// eslint-disable-next-line camelcase
received_at_ms,
};
})
// Fix order: from newest to oldest
.reverse();
return {
...(await this.fromStandardMessage(
rev.standardMessage,
mainMessage.conversationId
)),
timestamp,
received_at: incrementMessageCounter(),
sendStateByConversationId,
// eslint-disable-next-line camelcase
received_at_ms,
};
})
// Fix order: from newest to oldest
.reverse()
);
// See `ts/util/handleEditMessage.ts`, the first history entry is always
// the current message.
@ -1051,6 +1079,71 @@ export class BackupImportStream extends Writable {
return result;
}
private convertQuoteType(
type: Backups.Quote.Type | null | undefined
): SignalService.DataMessage.Quote.Type {
switch (type) {
case Backups.Quote.Type.GIFTBADGE:
return SignalService.DataMessage.Quote.Type.GIFT_BADGE;
case Backups.Quote.Type.NORMAL:
case Backups.Quote.Type.UNKNOWN:
case null:
case undefined:
return SignalService.DataMessage.Quote.Type.NORMAL;
default:
throw missingCaseError(type);
}
}
private async fromQuote(
quote: Backups.IQuote,
conversationId: string
): Promise<QuotedMessageType> {
strictAssert(quote.authorId != null, 'quote must have an authorId');
const authorConvo = this.recipientIdToConvo.get(quote.authorId.toNumber());
strictAssert(authorConvo !== undefined, 'author conversation not found');
strictAssert(
isAciString(authorConvo.serviceId),
'must have ACI for authorId in quote'
);
return copyFromQuotedMessage(
{
id: getTimestampFromLong(quote.targetSentTimestamp),
authorAci: authorConvo.serviceId,
text: dropNull(quote.text),
bodyRanges: quote.bodyRanges?.length
? filterAndClean(
quote.bodyRanges.map(range => ({
...range,
mentionAci: range.mentionAci
? Aci.parseFromServiceIdBinary(
Buffer.from(range.mentionAci)
).getServiceIdString()
: undefined,
}))
)
: undefined,
attachments:
quote.attachments?.map(quotedAttachment => {
const { fileName, contentType, thumbnail } = quotedAttachment;
return {
fileName: dropNull(fileName),
contentType: contentType
? stringToMIMEType(contentType)
: APPLICATION_OCTET_STREAM,
thumbnail: thumbnail?.pointer
? convertFilePointerToAttachment(thumbnail.pointer)
: undefined,
};
}) ?? [],
type: this.convertQuoteType(quote.type),
},
conversationId
);
}
private fromReactions(
reactions: ReadonlyArray<Backups.IReaction> | null | undefined
): Array<MessageReactionType> | undefined {

View file

@ -289,37 +289,41 @@ export async function getFilePointerForAttachment({
};
}
// From here on, this attachment is headed to (or already on) the backup tier!
const mediaNameForCurrentVersionOfAttachment =
getMediaNameForAttachment(attachment);
// Some attachments (e.g. those quoted ones copied from the original message) may not
// have any encryption info, including a digest!
if (attachment.digest) {
// From here on, this attachment is headed to (or already on) the backup tier!
const mediaNameForCurrentVersionOfAttachment =
getMediaNameForAttachment(attachment);
const backupCdnInfo = await getBackupCdnInfo(
getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string
);
const backupCdnInfo = await getBackupCdnInfo(
getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string
);
// We can generate a backupLocator for this mediaName iff
// 1. we have iv, key, and digest so we can re-encrypt to the existing digest when
// uploading, or
// 2. the mediaId is already in the backup tier and we have the key & digest to decrypt
// and verify it
if (
isReencryptableToSameDigest(attachment) ||
(backupCdnInfo.isInBackupTier && isDecryptable(attachment))
) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator({
...attachment,
backupLocator: {
mediaName: mediaNameForCurrentVersionOfAttachment,
cdnNumber: backupCdnInfo.isInBackupTier
? backupCdnInfo.cdnNumber
: undefined,
},
// We can generate a backupLocator for this mediaName iff
// 1. we have iv, key, and digest so we can re-encrypt to the existing digest when
// uploading, or
// 2. the mediaId is already in the backup tier and we have the key & digest to
// decrypt and verify it
if (
isReencryptableToSameDigest(attachment) ||
(backupCdnInfo.isInBackupTier && isDecryptable(attachment))
) {
return {
filePointer: new Backups.FilePointer({
...filePointerRootProps,
backupLocator: getBackupLocator({
...attachment,
backupLocator: {
mediaName: mediaNameForCurrentVersionOfAttachment,
cdnNumber: backupCdnInfo.isInBackupTier
? backupCdnInfo.cdnNumber
: undefined,
},
}),
}),
}),
};
};
}
}
log.info(`${logId}: Generating new encryption info for attachment`);

View file

@ -8,13 +8,16 @@ import { omit } from 'lodash';
import type { ConversationModel } from '../../models/conversations';
import * as Bytes from '../../Bytes';
import Data from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { type AciString, generateAci } from '../../types/ServiceId';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
import { AUDIO_MP3, IMAGE_JPEG } from '../../types/MIME';
import type { MessageAttributesType } from '../../model-types';
import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME';
import type {
MessageAttributesType,
QuotedMessageType,
} from '../../model-types';
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import { SignalService } from '../../protobuf';
@ -58,6 +61,13 @@ describe('backup/attachments', () => {
contentType: IMAGE_JPEG,
path: `/path/to/file${index}.png`,
uploadTimestamp: index,
thumbnail: {
size: 1024,
width: 150,
height: 150,
contentType: IMAGE_PNG,
path: '/path/to/thumbnail.png',
},
...overrides,
};
}
@ -73,7 +83,7 @@ describe('backup/attachments', () => {
received_at: timestamp,
received_at_ms: timestamp,
sourceServiceId: CONTACT_A,
sourceDevice: timestamp,
sourceDevice: 1,
sent_at: timestamp,
timestamp,
readStatus: ReadStatus.Read,
@ -97,8 +107,8 @@ describe('backup/attachments', () => {
[
composeMessage(1, {
attachments: [
omit(attachment1, ['path', 'iv']),
omit(attachment2, ['path', 'iv']),
omit(attachment1, ['path', 'iv', 'thumbnail']),
omit(attachment2, ['path', 'iv', 'thumbnail']),
],
}),
],
@ -121,7 +131,12 @@ describe('backup/attachments', () => {
// but there will be a backupLocator
attachments: [
{
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
...omit(attachment, [
'path',
'iv',
'thumbnail',
'uploadTimestamp',
]),
backupLocator: { mediaName: attachment.digest },
},
],
@ -148,7 +163,12 @@ describe('backup/attachments', () => {
composeMessage(1, {
attachments: [
{
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
...omit(attachment, [
'path',
'iv',
'thumbnail',
'uploadTimestamp',
]),
backupLocator: { mediaName: attachment.digest },
},
],
@ -173,7 +193,11 @@ describe('backup/attachments', () => {
[
composeMessage(1, {
preview: [
{ url: 'url', date: 1, image: omit(attachment, ['path', 'iv']) },
{
url: 'url',
date: 1,
image: omit(attachment, ['path', 'iv', 'thumbnail']),
},
],
}),
],
@ -209,7 +233,12 @@ describe('backup/attachments', () => {
image: {
// path, iv, and uploadTimestamp will not be roundtripped,
// but there will be a backupLocator
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
...omit(attachment, [
'path',
'iv',
'thumbnail',
'uploadTimestamp',
]),
backupLocator: { mediaName: attachment.digest },
},
},
@ -237,7 +266,7 @@ describe('backup/attachments', () => {
contact: [
{
avatar: {
avatar: omit(attachment, ['path', 'iv']),
avatar: omit(attachment, ['path', 'iv', 'thumbnail']),
isProfile: false,
},
},
@ -265,7 +294,12 @@ describe('backup/attachments', () => {
{
avatar: {
avatar: {
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
...omit(attachment, [
'path',
'iv',
'thumbnail',
'uploadTimestamp',
]),
backupLocator: { mediaName: attachment.digest },
},
isProfile: false,
@ -278,4 +312,155 @@ describe('backup/attachments', () => {
);
});
});
describe('quotes', () => {
it('BackupLevel.Messages, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1);
const authorAci = generateAci();
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,
id: Date.now(),
referencedMessageNotFound: false,
messageId: '',
isGiftBadge: true,
attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }],
};
await asymmetricRoundtripHarness(
[
composeMessage(1, {
quote: quotedMessage,
}),
],
// path & iv will not be roundtripped
[
composeMessage(1, {
quote: {
...quotedMessage,
referencedMessageNotFound: true,
attachments: [
{
thumbnail: omit(attachment, ['iv', 'path', 'thumbnail']),
contentType: VIDEO_MP4,
},
],
},
}),
],
BackupLevel.Messages
);
});
it('BackupLevel.Media, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1);
strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci();
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,
id: Date.now(),
referencedMessageNotFound: false,
messageId: '',
isGiftBadge: true,
attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }],
};
await asymmetricRoundtripHarness(
[
composeMessage(1, {
quote: quotedMessage,
}),
],
[
composeMessage(1, {
quote: {
...quotedMessage,
referencedMessageNotFound: true,
attachments: [
{
thumbnail: {
...omit(attachment, [
'iv',
'path',
'uploadTimestamp',
'thumbnail',
]),
backupLocator: { mediaName: attachment.digest },
},
contentType: VIDEO_MP4,
},
],
},
}),
],
BackupLevel.Media
);
});
it('Copies data from message if it exists', async () => {
const existingAttachment = composeAttachment(1);
const existingMessageTimestamp = Date.now();
const existingMessage = composeMessage(existingMessageTimestamp, {
attachments: [existingAttachment],
});
const quoteAttachment = composeAttachment(2);
delete quoteAttachment.thumbnail;
strictAssert(quoteAttachment.digest, 'digest exists');
strictAssert(existingAttachment.digest, 'digest exists');
const quotedMessage: QuotedMessageType = {
authorAci: existingMessage.sourceServiceId as AciString,
isViewOnce: false,
id: existingMessageTimestamp,
referencedMessageNotFound: false,
messageId: '',
isGiftBadge: false,
attachments: [{ thumbnail: quoteAttachment, contentType: VIDEO_MP4 }],
};
const quoteMessage = composeMessage(existingMessageTimestamp + 1, {
quote: quotedMessage,
});
await asymmetricRoundtripHarness(
[existingMessage, quoteMessage],
[
{
...existingMessage,
attachments: [
{
...omit(existingAttachment, [
'path',
'iv',
'uploadTimestamp',
'thumbnail',
]),
backupLocator: { mediaName: existingAttachment.digest },
},
],
},
{
...quoteMessage,
quote: {
...quotedMessage,
referencedMessageNotFound: false,
attachments: [
{
// The thumbnail will not have been copied over yet since it has not yet
// been downloaded
thumbnail: {
...omit(quoteAttachment, ['iv', 'path', 'uploadTimestamp']),
backupLocator: { mediaName: quoteAttachment.digest },
},
contentType: VIDEO_MP4,
},
],
},
},
],
BackupLevel.Media
);
});
});
});

View file

@ -1073,6 +1073,13 @@ export function isDownloadableFromBackupTier(
return false;
}
export function isDownloadable(attachment: AttachmentType): boolean {
return (
isDownloadableFromTransitTier(attachment) ||
isDownloadableFromBackupTier(attachment)
);
}
export function isAttachmentLocallySaved(
attachment: AttachmentType
): attachment is LocallySavedAttachment {