Improve message content cleanup behavior

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2025-12-09 09:41:50 -06:00
committed by GitHub
parent 4cd4e7a604
commit 981fd638f9
11 changed files with 239 additions and 42 deletions

View File

@@ -613,7 +613,7 @@ export async function handleDataMessage(
const isSupported = !isUnsupportedMessage(message.attributes); const isSupported = !isUnsupportedMessage(message.attributes);
if (!isSupported) { if (!isSupported) {
await eraseMessageContents(message); await eraseMessageContents(message, 'unsupported-message');
} }
if (isSupported) { if (isSupported) {
@@ -735,7 +735,7 @@ export async function handleDataMessage(
} }
if (isTapToView(message.attributes) && type === 'outgoing') { if (isTapToView(message.attributes) && type === 'outgoing') {
await eraseMessageContents(message); await eraseMessageContents(message, 'view-once-viewed');
} }
if ( if (
@@ -749,7 +749,7 @@ export async function handleDataMessage(
message.set({ message.set({
isTapToViewInvalid: true, isTapToViewInvalid: true,
}); });
await eraseMessageContents(message); await eraseMessageContents(message, 'view-once-invalid');
} }
} }

View File

@@ -35,6 +35,10 @@ export class MessageModel {
window.MessageCache._updateCaches(this); window.MessageCache._updateCaches(this);
} }
public resetAllAttributes(attributes: MessageAttributesType): void {
this.#_attributes = { ...attributes };
window.MessageCache._updateCaches(this);
}
public get attributes(): Readonly<MessageAttributesType> { public get attributes(): Readonly<MessageAttributesType> {
return this.#_attributes; return this.#_attributes;

View File

@@ -94,7 +94,7 @@ export async function markViewOnceMessageViewed(
message.set(markViewed(message.attributes)); message.set(markViewed(message.attributes));
} }
await eraseMessageContents(message); await eraseMessageContents(message, 'view-once-viewed');
if (!fromSync) { if (!fromSync) {
const senderE164 = getSource(message.attributes); const senderE164 = getSource(message.attributes);

View File

@@ -43,7 +43,7 @@ async function eraseTapToViewMessages() {
// We do this to update the UI, if this message is being displayed somewhere // We do this to update the UI, if this message is being displayed somewhere
window.reduxActions.conversations.messageExpired(message.id); window.reduxActions.conversations.messageExpired(message.id);
await eraseMessageContents(message); await eraseMessageContents(message, 'view-once-expired');
}) })
); );
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,59 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v7 } from 'uuid';
import { assert } from 'chai';
import { eraseMessageContents } from '../../util/cleanup.preload.js';
import { MessageModel } from '../../models/messages.preload.js';
import type { PollMessageAttribute } from '../../types/Polls.dom.js';
import { DataWriter } from '../../sql/Client.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { generateAci } from '../../types/ServiceId.std.js';
import type { MessageAttributesType } from '../../model-types.js';
import { IMAGE_BMP } from '../../types/MIME.std.js';
import { SendStatus } from '../../messages/MessageSendState.std.js';
describe('eraseMessageContents', () => {
beforeEach(async () => {
await DataWriter.removeAll();
await itemStorage.user.setAciAndDeviceId(generateAci(), 1);
await window.ConversationController.load();
});
it('only preserves explicitly preserved fields', async () => {
const now = Date.now();
const attributes: MessageAttributesType = {
id: v7(),
type: 'incoming',
sent_at: now,
received_at: now,
conversationId: 'convoId',
timestamp: now,
schemaVersion: 12,
body: 'body',
poll: {
question: 'poll question',
} as PollMessageAttribute,
sendStateByConversationId: { aci: { status: SendStatus.Delivered } },
storyReplyContext: {
attachment: { contentType: IMAGE_BMP, size: 128 },
messageId: 'messageId',
},
};
const message = new MessageModel(attributes);
await eraseMessageContents(message, 'unsupported-message');
assert.deepEqual(message.attributes, {
id: attributes.id,
type: attributes.type,
sent_at: attributes.sent_at,
received_at: attributes.received_at,
conversationId: 'convoId',
timestamp: attributes.timestamp,
schemaVersion: 12,
sendStateByConversationId: { aci: { status: SendStatus.Delivered } },
isErased: true,
});
});
});

View File

@@ -1,9 +1,12 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.js';
import { strictAssert } from '../util/assert.std.js';
import type { DurationInSeconds } from '../util/durations/index.std.js'; import type { DurationInSeconds } from '../util/durations/index.std.js';
import type { AttachmentType } from './Attachment.std.js'; import type { AttachmentType } from './Attachment.std.js';
import type { EmbeddedContactType } from './EmbeddedContact.std.js'; import type { EmbeddedContactType } from './EmbeddedContact.std.js';
import type { ErrorIfOverlapping, ExactKeys } from './Util.std.js';
export function getMentionsRegex(): RegExp { export function getMentionsRegex(): RegExp {
return /\uFFFC/g; return /\uFFFC/g;
@@ -92,3 +95,115 @@ export type MessageSchemaVersion6 = Partial<
contact: Array<EmbeddedContactType>; contact: Array<EmbeddedContactType>;
}> }>
>; >;
// NB: see `eraseMessageContents` for all scenarios in which message content can be erased
export const messageAttrsToPreserveAfterErase = [
// TS required fields
'id',
'timestamp',
'conversationId',
'type',
'sent_at',
'received_at',
// all other, non-TS-required fields to preserve
'canReplyToStory',
'deletedForEveryone',
'deletedForEveryoneFailed',
'deletedForEveryoneSendStatus',
'deletedForEveryoneTimestamp',
'editMessageReceivedAt',
'editMessageReceivedAtMs',
'editMessageTimestamp',
'errors',
'expirationStartTimestamp',
'expireTimer',
'isErased',
'isTapToViewInvalid',
'isViewOnce',
'readAt',
'readStatus',
'received_at_ms',
'requiredProtocolVersion',
'schemaMigrationAttempts',
'schemaVersion',
'seenStatus',
'sendStateByConversationId',
'serverGuid',
'serverTimestamp',
'source',
'sourceDevice',
'sourceServiceId',
'storyId',
'synced',
'unidentifiedDeliveries',
] as const;
const messageAttrsToErase = [
'attachments',
'body',
'bodyAttachment',
'bodyRanges',
'callId',
'changedId',
'contact',
'conversationMerge',
'dataMessage',
'decrypted_at',
'droppedGV2MemberIds',
'editHistory',
'expirationTimerUpdate',
'flags',
'giftBadge',
'group_update',
'groupMigration',
'groupV2Change',
'hasUnreadPollVotes',
'invitedGV2Members',
'unidentifiedDeliveryReceived',
'key_changed',
'local',
'logger',
'mentionsMe',
'message',
'messageRequestResponseEvent',
'messageTimer',
'payment',
'phoneNumberDiscovery',
'pinnedMessageId',
'poll',
'pollTerminateNotification',
'preview',
'profileChange',
'quote',
'reactions',
'sendHQImages',
'sms',
'sticker',
'storyDistributionListId',
'storyReaction',
'storyRecipientsVersion',
'storyReplyContext',
'supportedVersionAtReceive',
'titleTransition',
'verified',
'verifiedChanged',
] as const;
const allKeys = [
...messageAttrsToPreserveAfterErase,
...messageAttrsToErase,
] as const;
// Note: if this errors, it's likely that the keys of MessageAttributesType have changed
// and you need to update messageAttrsToPreserveAfterErase or
// messageAttributesToEraseIfMessageContentsAreErased as needed
const _enforceTypeCheck: ExactKeys<MessageAttributesType, typeof allKeys> =
{} as MessageAttributesType;
strictAssert(_enforceTypeCheck != null, 'type check');
const _checkKeys: ErrorIfOverlapping<
typeof messageAttrsToPreserveAfterErase,
typeof messageAttrsToErase
> = undefined;
strictAssert(_checkKeys === undefined, 'type check');

View File

@@ -115,3 +115,19 @@ export type WithRequiredProperties<T, P extends keyof T> = Omit<T, P> &
export type WithOptionalProperties<T, P extends keyof T> = Omit<T, P> & export type WithOptionalProperties<T, P extends keyof T> = Omit<T, P> &
Partial<Pick<T, P>>; Partial<Pick<T, P>>;
// Check that two const arrays do not have overlapping values
export type ErrorIfOverlapping<
T1 extends ReadonlyArray<unknown>,
T2 extends ReadonlyArray<unknown>,
> = T1[number] & T2[number] extends never
? void
: 'Error: Arrays have overlapping values';
// Check that T has all the fields (and only those fields) from K
export type ExactKeys<T, K extends ReadonlyArray<string>> =
Exclude<keyof T, K[number]> extends never
? Exclude<K[number], keyof T> extends never
? T
: 'Error: Array has fields not present in object type'
: 'Error: Object type has keys not present in array';

View File

@@ -3,6 +3,7 @@
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { batch } from 'react-redux'; import { batch } from 'react-redux';
import { pick } from 'lodash';
import type { MessageAttributesType } from '../model-types.d.ts'; import type { MessageAttributesType } from '../model-types.d.ts';
import { MessageModel } from '../models/messages.preload.js'; import { MessageModel } from '../models/messages.preload.js';
@@ -30,6 +31,7 @@ import { hydrateStoryContext } from './hydrateStoryContext.preload.js';
import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.js'; import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.js';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService.preload.js'; import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService.preload.js';
import { throttledUpdateBackupMediaDownloadProgress } from './updateBackupMediaDownloadProgress.preload.js'; import { throttledUpdateBackupMediaDownloadProgress } from './updateBackupMediaDownloadProgress.preload.js';
import { messageAttrsToPreserveAfterErase } from '../types/Message.std.js';
const log = createLogger('cleanup'); const log = createLogger('cleanup');
@@ -40,11 +42,16 @@ export async function postSaveUpdates(): Promise<void> {
export async function eraseMessageContents( export async function eraseMessageContents(
message: MessageModel, message: MessageModel,
additionalProperties = {}, reason:
shouldPersist = true | 'view-once-viewed'
| 'view-once-invalid'
| 'view-once-expired'
| 'unsupported-message'
| 'delete-for-everyone',
additionalProperties = {}
): Promise<void> { ): Promise<void> {
log.info( log.info(
`Erasing data for message ${getMessageIdForLogging(message.attributes)}` `Erasing data for message ${getMessageIdForLogging(message.attributes)}: ${reason}`
); );
// Note: There are cases where we want to re-erase a given message. For example, when // Note: There are cases where we want to re-erase a given message. For example, when
@@ -59,27 +66,22 @@ export async function eraseMessageContents(
); );
} }
message.set({ const preservedAttributes = pick(
attachments: [], message.attributes,
body: '', ...messageAttrsToPreserveAfterErase
bodyRanges: undefined, );
contact: [],
editHistory: undefined, message.resetAllAttributes({
hasUnreadPollVotes: undefined, ...preservedAttributes,
isErased: true, isErased: true,
preview: [],
poll: undefined,
quote: undefined,
sticker: undefined,
...additionalProperties, ...additionalProperties,
}); });
window.ConversationController.get( window.ConversationController.get(
message.attributes.conversationId message.attributes.conversationId
)?.debouncedUpdateLastMessage(); )?.debouncedUpdateLastMessage();
if (shouldPersist) {
await window.MessageCache.saveMessage(message.attributes); await window.MessageCache.saveMessage(message.attributes);
}
await DataWriter.deleteSentProtoByMessageId(message.id); await DataWriter.deleteSentProtoByMessageId(message.id);
} }

View File

@@ -8,9 +8,9 @@ import { isMe } from './whatTypeOfConversation.dom.js';
import { getAuthorId } from '../messages/sources.preload.js'; import { getAuthorId } from '../messages/sources.preload.js';
import { isStory } from '../state/selectors/message.preload.js'; import { isStory } from '../state/selectors/message.preload.js';
import { isTooOldToModifyMessage } from './isTooOldToModifyMessage.std.js'; import { isTooOldToModifyMessage } from './isTooOldToModifyMessage.std.js';
import { drop } from './drop.std.js';
import { eraseMessageContents } from './cleanup.preload.js'; import { eraseMessageContents } from './cleanup.preload.js';
import { notificationService } from '../services/notifications.preload.js'; import { notificationService } from '../services/notifications.preload.js';
import { DataWriter } from '../sql/Client.preload.js';
const log = createLogger('deleteForEveryone'); const log = createLogger('deleteForEveryone');
@@ -19,8 +19,7 @@ export async function deleteForEveryone(
doe: Pick< doe: Pick<
DeleteAttributesType, DeleteAttributesType,
'fromId' | 'targetSentTimestamp' | 'serverTimestamp' 'fromId' | 'targetSentTimestamp' | 'serverTimestamp'
>, >
shouldPersist = true
): Promise<void> { ): Promise<void> {
if (isDeletionByMe(message, doe)) { if (isDeletionByMe(message, doe)) {
const conversation = window.ConversationController.get( const conversation = window.ConversationController.get(
@@ -36,7 +35,7 @@ export async function deleteForEveryone(
return; return;
} }
await handleDeleteForEveryone(message, doe, shouldPersist); await handleDeleteForEveryone(message, doe);
return; return;
} }
@@ -51,7 +50,7 @@ export async function deleteForEveryone(
return; return;
} }
await handleDeleteForEveryone(message, doe, shouldPersist); await handleDeleteForEveryone(message, doe);
} }
function isDeletionByMe( function isDeletionByMe(
@@ -71,8 +70,7 @@ export async function handleDeleteForEveryone(
del: Pick< del: Pick<
DeleteAttributesType, DeleteAttributesType,
'fromId' | 'targetSentTimestamp' | 'serverTimestamp' 'fromId' | 'targetSentTimestamp' | 'serverTimestamp'
>, >
shouldPersist = true
): Promise<void> { ): Promise<void> {
if (message.deletingForEveryone || message.get('deletedForEveryone')) { if (message.deletingForEveryone || message.get('deletedForEveryone')) {
return; return;
@@ -94,18 +92,21 @@ export async function handleDeleteForEveryone(
notificationService.removeBy({ messageId: message.get('id') }); notificationService.removeBy({ messageId: message.get('id') });
// Erase the contents of this message // Erase the contents of this message
await eraseMessageContents( await eraseMessageContents(message, 'delete-for-everyone', {
message, deletedForEveryone: true,
{ deletedForEveryone: true, reactions: [] }, reactions: [],
shouldPersist });
);
// Update the conversation's last message in case this was the last message // We delete the message first, before re-saving it -- this causes any foreign key ON
drop( // DELETE CASCADE and messages_on_delete triggers to run, which is important
window.ConversationController.get( await DataWriter.removeMessage(message.attributes.id, {
message.attributes.conversationId cleanupMessages: async () => {
)?.updateLastMessage() // We don't actually want to remove this message up from in-memory caches
); },
});
await window.MessageCache.saveMessage(message.attributes, {
forceSave: true,
});
} finally { } finally {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
message.deletingForEveryone = undefined; message.deletingForEveryone = undefined;

View File

@@ -74,7 +74,7 @@ export async function hydrateStoryContext(
); );
const newMessageAttributes: Partial<MessageAttributesType> = { const newMessageAttributes: Partial<MessageAttributesType> = {
storyReplyContext: { storyReplyContext: {
...context, authorAci: context?.authorAci,
attachment: undefined, attachment: undefined,
// No messageId = referenced story not found // No messageId = referenced story not found
messageId: '', messageId: '',

View File

@@ -355,7 +355,7 @@ export async function modifyTargetMessage(
const deletes = Deletes.forMessage(message.attributes); const deletes = Deletes.forMessage(message.attributes);
await Promise.all( await Promise.all(
deletes.map(async del => { deletes.map(async del => {
await deleteForEveryone(message, del, false); await deleteForEveryone(message, del);
changed = true; changed = true;
}) })
); );