diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8436437458..89e2850003 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1678,6 +1678,10 @@ "messageformat": "All messages in this chat will be deleted from all your devices.", "description": "Description for confirmation modal to delete a conversation" }, + "icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync--still-member": { + "messageformat": "All messages in this chat will be deleted from all your devices. You will still be a member of this group.", + "description": "Description for confirmation modal to delete a conversation, if you are still part of the group." + }, "icu:ConversationHeader__ContextMenu__LeaveGroupAction__title": { "messageformat": "Leave group", "description": "This is a button to leave a group" diff --git a/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx b/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx index 5b7dee1df7..0c9dc9dfab 100644 --- a/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx +++ b/ts/components/DeleteMessagesConfirmationDialog.dom.stories.tsx @@ -18,6 +18,18 @@ export function Default(): JSX.Element { i18n={i18n} onClose={action('onClose')} onDestroyMessages={action('onDestroyMessages')} + areWeMember={false} + /> + ); +} + +export function DefaultStillMember(): JSX.Element { + return ( + ); } diff --git a/ts/components/DeleteMessagesConfirmationDialog.dom.tsx b/ts/components/DeleteMessagesConfirmationDialog.dom.tsx index c3be8ef370..676d4258b2 100644 --- a/ts/components/DeleteMessagesConfirmationDialog.dom.tsx +++ b/ts/components/DeleteMessagesConfirmationDialog.dom.tsx @@ -6,16 +6,22 @@ import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx'; export function DeleteMessagesConfirmationDialog({ i18n, + areWeMember, onDestroyMessages, onClose, }: { i18n: LocalizerType; + areWeMember: boolean; onDestroyMessages: () => void; onClose: () => void; }): JSX.Element { - const dialogBody = i18n( - 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync' - ); + const dialogBody = areWeMember + ? i18n( + 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync--still-member' + ) + : i18n( + 'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync' + ); return ( { setHasDeleteMessagesConfirmation(false); }} + areWeMember={areWeMember} /> )} {hasLeaveGroupConfirmation && ( diff --git a/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx index 5cc2f5be06..108616c298 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsActions.dom.tsx @@ -473,6 +473,7 @@ export function ConversationDetailsActions({ onClose={() => { gGroupDelete(false); }} + areWeMember={!left && !isGroupTerminated} /> )} diff --git a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx index 2d88913a88..ffbdc9ce81 100644 --- a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx +++ b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx @@ -293,6 +293,11 @@ export const LeftPaneConversationListItemContextMenu: FC )} diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index ea8fe36d4b..f993a9f68f 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -237,7 +237,14 @@ import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus.std import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes.preload.ts'; import { getIsInitialContactSync } from '../services/contactSync.preload.ts'; import { queueAttachmentDownloadsAndMaybeSaveMessage } from '../util/queueAttachmentDownloads.preload.ts'; -import { cleanupMessages } from '../util/cleanup.preload.ts'; +import { + safeCleanupAvatarDraftFiles, + safeCleanupAvatarFiles, + safeCleanupDraftFiles, + cleanupMessages, + GENERIC_CLEANUP_FIELDS, + GROUP_CLEANUP_FIELDS, +} from '../util/cleanup.preload.ts'; import { MessageModel } from './messages.preload.ts'; import { applyNewAvatar, @@ -5520,15 +5527,18 @@ export class ConversationModel { source: 'message-request' | 'local-delete-sync' | 'local-delete'; }): Promise { const logId = `${providedLogId}/destroyMessagesInner`; - this.set({ - lastMessage: null, - lastMessageAuthor: null, - lastMessageAuthorAci: undefined, - timestamp: null, - active_at: null, - pendingUniversalTimer: undefined, - messagesDeleted: true, - }); + await safeCleanupDraftFiles(this.attributes); + await safeCleanupAvatarDraftFiles(this.attributes); + this.set(GENERIC_CLEANUP_FIELDS); + + if ( + isGroup(this.attributes) && + (this.get('left') || this.get('terminated')) + ) { + await safeCleanupAvatarFiles(this.attributes); + this.set(GROUP_CLEANUP_FIELDS); + } + await DataWriter.updateConversation(this.attributes); if ( diff --git a/ts/services/storageRecordOps.preload.ts b/ts/services/storageRecordOps.preload.ts index c979ed8a5a..41c7fe87fa 100644 --- a/ts/services/storageRecordOps.preload.ts +++ b/ts/services/storageRecordOps.preload.ts @@ -1293,6 +1293,9 @@ export async function mergeGroupV2Record( addUnknownFieldsToConversation(groupV2Record, conversation, details); + const deletedAndTerminated = + conversation.get('messagesDeleted') && conversation.get('terminated'); + if (isGroupV1(conversation.attributes)) { // If we found a GroupV1 conversation from this incoming GroupV2 record, we need to // migrate it! @@ -1304,7 +1307,7 @@ export async function mergeGroupV2Record( conversation, }) ); - } else { + } else if (!deletedAndTerminated) { const isFirstSync = !itemStorage.get('storageFetchComplete'); const dropInitialJoinMessage = isFirstSync; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 83ddd31dce..aade5a0fc0 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -312,9 +312,12 @@ import { createMessagesOnInsertTrigger } from './migrations/1500-search-polls.st import { isValidPlaintextHash } from '../types/Crypto.std.ts'; import { Emoji } from '../axo/emoji.std.ts'; import { WalCheckpoints } from './WalCheckpoints.std.ts'; +import { + getExternalDraftFilesForConversation, + getExternalAvatarFilesForConversation, +} from '../util/conversationFilePaths.std.ts'; const { - forEach, fromPairs, groupBy, isBoolean, @@ -1033,7 +1036,7 @@ export function initialize({ // Only the first worker gets to upgrade the schema. The rest just folow. if (isPrimary) { - updateSchema(db, logger); + updateSchema(db, logger, { userDataPath: configDir }); WalCheckpoints.setupDeleteTriggers(db, logger); } @@ -1049,7 +1052,10 @@ export function initialize({ } /** @testexport */ -export function setupTests(db: WritableDB): void { +export function setupTests( + db: WritableDB, + data: { userDataPath: string } +): void { const silentLogger = { ...consoleLogger, info: noop, @@ -1059,7 +1065,7 @@ export function setupTests(db: WritableDB): void { }; logger = silentLogger; - updateSchema(db, logger); + updateSchema(db, logger, data); } function closeReadable(db: ReadableDB): void { @@ -8987,47 +8993,6 @@ function getMessageServerGuidsForSpam( .all({ conversationId, limit }); } -function getExternalFilesForConversation( - conversation: Pick -): Array { - const { avatar, profileAvatar } = conversation; - const files: Array = []; - - if (avatar && avatar.path) { - files.push(avatar.path); - } - - if (profileAvatar && profileAvatar.path) { - files.push(profileAvatar.path); - } - - return files; -} - -function getExternalDraftFilesForConversation( - conversation: Pick -): Array { - const draftAttachments = conversation.draftAttachments || []; - const files: Array = []; - - forEach(draftAttachments, attachment => { - if (attachment.pending) { - return; - } - - const { path: file, screenshotPath } = attachment; - if (file) { - files.push(file); - } - - if (screenshotPath) { - files.push(screenshotPath); - } - }); - - return files; -} - function getKnownMessageAttachments( db: ReadableDB, cursor?: MessageAttachmentsCursorType @@ -9251,7 +9216,7 @@ function getKnownConversationAttachments(db: ReadableDB): Array { jsonToObject(row.json) ); conversations.forEach(conversation => { - const externalFiles = getExternalFilesForConversation(conversation); + const externalFiles = getExternalAvatarFilesForConversation(conversation); externalFiles.forEach(file => result.add(file)); }); diff --git a/ts/sql/migrations/1740-cleanup-groups.node.ts b/ts/sql/migrations/1740-cleanup-groups.node.ts new file mode 100644 index 0000000000..3a5078774c --- /dev/null +++ b/ts/sql/migrations/1740-cleanup-groups.node.ts @@ -0,0 +1,579 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { unlinkSync } from 'node:fs'; +import { join, normalize } from 'node:path'; + +import z from 'zod'; +import type { ReadonlyDeep } from 'type-fest'; + +import { jsonToObject, objectToJSON } from '../util.std.ts'; +import { ID_LENGTH } from '../../types/groups.std.ts'; +import { + getAttachmentsPath, + getAvatarsPath, + getDraftPath, +} from '../../../app/attachments.node.ts'; +import { isPathInside } from '../../util/isPathInside.node.ts'; +import { toLogFormat } from '../../types/errors.std.ts'; + +import type { LoggerType } from '../../types/Logging.std.ts'; +import type { WritableDB } from '../Interface.std.ts'; +import type { AvatarColorType } from '../../types/Colors.std.ts'; +import type { AvatarIconType } from '../../types/Avatar.std.ts'; + +export default function updateToSchemaVersion1740( + db: WritableDB, + logger: LoggerType, + _startingVersion: number, + { userDataPath }: { userDataPath: string } +): void { + const updateConversationStmt = db.prepare( + ` + UPDATE conversations SET + json = $json, + members = $members, + name = $name + WHERE id = $id; + ` + ); + + const attachmentsPath = getAttachmentsPath(userDataPath); + const avatarsPath = getAvatarsPath(userDataPath); + const draftPath = getDraftPath(userDataPath); + + const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath); + const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath); + const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath); + + const cleanConversation = (convo: ConversationAttributesType) => { + if ( + convo.type === 'group' && + isGroupV2(convo) && + convo.active_at == null && + convo.messagesDeleted && + (convo.left || convo.terminated) + ) { + const logId = `cleanConversation(${convo.id}/groupv2(${convo.groupId})`; + logger.info(`${logId}: Cleaning...`); + + const draftAttachments = + getExternalDraftFilesForConversation(convo).map(getAbsoluteDraftPath); + draftAttachments.forEach((item, index) => { + try { + unlinkSync(item); + } catch (error) { + logger.error( + `${logId}: Failed to deleft draft file at index ${index}`, + toLogFormat(error) + ); + } + }); + + const avatarAttachments = getExternalAvatarFilesForConversation( + convo + ).map(getAbsoluteAttachmentPath); + avatarAttachments.forEach((item, index) => { + try { + unlinkSync(item); + } catch (error) { + logger.error( + `${logId}: Failed to delete avatar file at index ${index}`, + toLogFormat(error) + ); + } + }); + + const avatarDraftAttachments = getExternalAvatarDraftsForConversation( + convo + ).map(getAbsoluteAvatarPath); + avatarDraftAttachments.forEach((item, index) => { + try { + unlinkSync(item); + } catch (error) { + logger.error( + `${logId}: Failed to delete avatar draft index ${index}`, + toLogFormat(error) + ); + } + }); + + const cleaned = { + ...convo, + ...GENERIC_CLEANUP_FIELDS, + ...GROUP_CLEANUP_FIELDS, + }; + + let dbMembers: string | null; + if (cleaned.membersV2) { + dbMembers = cleaned.membersV2.map(item => item.aci).join(' '); + } else if (cleaned.members) { + dbMembers = cleaned.members.join(' '); + } else { + dbMembers = null; + } + + updateConversationStmt.run({ + id: cleaned.id, + json: objectToJSON(cleaned), + members: dbMembers, + name: cleaned.name ?? null, + }); + } + }; + + const allConversations = db + .prepare( + ` + SELECT json + FROM conversations + ORDER BY id ASC; + `, + { pluck: true } + ) + .all() + .map(json => jsonToObject(json)); + + logger.info( + `About to iterate through ${allConversations.length} conversations` + ); + + for (const convo of allConversations) { + cleanConversation(convo); + } +} + +function isGroupV2(convo: ConversationAttributesType): boolean { + return Boolean( + convo.groupVersion === 2 && + convo.groupId && + Buffer.from(convo.groupId, 'base64').byteLength === ID_LENGTH + ); +} + +type OpaqueType = object; + +// These types are copied so this migration doesn't change meaning over time + +// Copied from conversationFilePaths.std.ts +function getExternalAvatarFilesForConversation( + conversation: Pick +): Array { + const { avatar, profileAvatar } = conversation; + const files: Array = []; + + if (avatar && avatar.path) { + files.push(avatar.path); + } + + if (profileAvatar && profileAvatar.path) { + files.push(profileAvatar.path); + } + + return files; +} + +function getExternalAvatarDraftsForConversation( + conversation: Pick +): Array { + const { avatars } = conversation; + const files: Array = []; + + (avatars || []).forEach(item => { + if (item.imagePath) { + files.push(item.imagePath); + } + }); + + return files; +} + +function getExternalDraftFilesForConversation( + conversation: Pick +): Array { + const draftAttachments = conversation.draftAttachments || []; + const files: Array = []; + + (draftAttachments || []).forEach(attachment => { + if (attachment.pending) { + return; + } + + const { path: file, screenshotPath } = attachment; + if (file) { + files.push(file); + } + + if (screenshotPath) { + files.push(screenshotPath); + } + }); + + return files; +} + +// Copied from attachments.preload.ts - exported only for testing! +export const createAbsolutePathGetter = + (rootPath: string) => + (relativePath: string): string => { + const absolutePath = join(rootPath, relativePath); + const normalized = normalize(absolutePath); + if (!isPathInside(normalized, rootPath)) { + throw new Error('Invalid relative path'); + } + return normalized; + }; + +// Copied from Mime.std.ts +export const MIMETypeSchema = z.string().brand('mimeType'); +export type MIMEType = z.infer; + +// Copied from Attachment.std.ts +export type AddressableAttachmentType = Readonly<{ + version?: 1 | 2; + path: string; + localKey?: string; + size?: number; + contentType: MIMEType; + + // In-memory data, for outgoing attachments that are not saved to disk. + data?: Uint8Array; +}>; + +// Copied from Avatar.std.ts +export type ContactAvatarType = + | ({ + // Downloaded avatar + path: string; + url?: string; + hash?: string; + } & Partial) + | { + // Not-yet downloaded avatar + path?: undefined; + url: string; + hash?: string; + }; + +export type AvatarDataType = { + id: number | string; + buffer?: Uint8Array; + color?: AvatarColorType; + icon?: AvatarIconType; + text?: string; + imagePath?: string; + + // LocalAttachmentV2Type compatibility (except for `path` being `imagePath`) + version?: 2; + localKey?: string; + size?: number; +}; + +// Copied from model-types.d.ts, most complex types replaced with OpaqueType +export type GroupV2MemberType = { + aci: string; + role: OpaqueType; + joinedAtVersion: number; + labelString?: string; + labelEmoji?: OpaqueType; + + // Note that these are temporary flags, generated by applyGroupChange, but eliminated + // by applyGroupState. They are used to make our diff-generation more intelligent but + // not after that. + joinedFromLink?: boolean; + approvedByAdmin?: boolean; +}; + +export type ConversationAttributesType = { + accessKey?: string | null; + addedBy?: string; + badges?: Array< + | { id: string } + | { + id: string; + expiresAt: number; + isVisible: boolean; + } + >; + capabilities?: OpaqueType; + color?: string; + // If present - the numeric value of `color` (possibly not yet supported) that + // we got the from primary during either backup or storage service import. + colorFromPrimary?: number; + conversationColor?: OpaqueType; + customColor?: OpaqueType; + customColorId?: string; + + // Set at backup import time, exported as is. + wallpaperPhotoPointerBase64?: string; + wallpaperPreset?: number; + dimWallpaperInDarkMode?: boolean; + autoBubbleColor?: boolean; + + discoveredUnregisteredAt?: number; + firstUnregisteredAt?: number; + draftChanged?: boolean; + draftAttachments?: ReadonlyArray< + OpaqueType & { path?: string; pending?: boolean; screenshotPath?: string } + >; + draftBodyRanges?: OpaqueType; + draftIsViewOnce?: boolean; + draftTimestamp?: number | null; + hideStory?: boolean; + inbox_position?: number; + // When contact is removed - it is initially placed into `justNotification` + // removal stage. In this stage user can still send messages (which will + // set `removalStage` to `undefined`), but if a new incoming message arrives - + // the stage will progress to `messageRequest` and composition area will be + // replaced with a message request. + removalStage?: 'justNotification' | 'messageRequest'; + isPinned?: boolean; + lastMessageDeletedForEveryone?: boolean; + lastMessageDeletedForEveryoneByAdminAci?: OpaqueType; + lastMessageAuthorAci?: OpaqueType | null; + lastMessage?: string | null; + lastMessageBodyRanges?: ReadonlyArray; + lastMessagePrefix?: OpaqueType; + /** @deprecated Use lastMessageAuthorAci instead */ + lastMessageAuthor?: string | null; + lastMessageStatus?: OpaqueType | null; + lastMessageReceivedAt?: number; + lastMessageReceivedAtMs?: number; + markedUnread?: boolean; + messageCount?: number; + messageCountBeforeMessageRequests?: number | null; + messageRequestResponseType?: number; + messagesDeleted?: boolean; + muteExpiresAt?: number; + dontNotifyForMentionsIfMuted?: boolean; + sharingPhoneNumber?: boolean; + profileAvatar?: ContactAvatarType | null; + profileKeyCredential?: string | null; + profileKeyCredentialExpiration?: number | null; + lastProfile?: OpaqueType; + needsTitleTransition?: boolean; + quotedMessageId?: string | null; + /** + * TODO: Rename this key to be specific to the accessKey on the conversation + * It's not used for group endorsements. + */ + sealedSender?: OpaqueType; + sentMessageCount?: number; + voiceNotePlaybackRate?: number; + + id: string; + type: 'private' | 'group'; + timestamp?: number | null; + + // Shared fields + active_at?: number | null; + draft?: string | null; + draftEditMessage?: OpaqueType; + hasPostedStory?: boolean; + isArchived?: boolean; + isReported?: boolean; + name?: string; + systemGivenName?: string; + systemFamilyName?: string; + systemNickname?: string; + nicknameGivenName?: string | null; + nicknameFamilyName?: string | null; + note?: string | null; + needsStorageServiceSync?: boolean; + needsVerification?: boolean; + profileSharing?: boolean; + storageID?: string; + storageVersion?: number; + storageUnknownFields?: string; + unreadCount?: number; + unreadMentionsCount?: number; + version: number; + + // Private core info + serviceId?: OpaqueType; + pni?: OpaqueType; + pniSignatureVerified?: boolean; + e164?: string; + + // Private other fields + about?: string; + aboutEmoji?: OpaqueType; + profileFamilyName?: string; + profileKey?: string; + profileName?: string; + verified?: number; + profileLastUpdatedAt?: number; + profileLastFetchedAt?: number; + pendingUniversalTimer?: string; + pendingRemovedContactNotification?: string; + username?: string; + shareMyPhoneNumber?: boolean; + previousIdentityKey?: string; + reportingToken?: string; + + // Group-only + groupId?: string; + // A shorthand, representing whether the user is part of the group. Not strictly for + // when the user manually left the group. But historically, that was the only way + // to leave a group. + left?: boolean; + groupVersion?: number; + storySendMode?: OpaqueType; + groupVerifiedNameHash?: string; + + // GroupV1 only + members?: Array; + derivedGroupV2Id?: string; + + // GroupV2 core info + masterKey?: string; + secretParams?: string; + publicParams?: string; + revision?: number; + senderKeyInfo?: OpaqueType; + needsGroupUpdate?: boolean; // `true` only for groups we learned about through + // an incoming message. Reset when we update the + // group or fail. + + // GroupV2 other fields + accessControl?: OpaqueType; + announcementsOnly?: boolean; + terminated?: boolean; + avatar?: ContactAvatarType | null; + avatars?: ReadonlyArray>; + description?: string; + expireTimer?: OpaqueType; + expireTimerVersion: number; + membersV2?: Array; + pendingMembersV2?: Array; + pendingAdminApprovalV2?: Array; + bannedMembersV2?: Array; + groupInviteLinkPassword?: string; + previousGroupV1Id?: string; + previousGroupV1Members?: Array; + acknowledgedGroupNameCollisions?: ReadonlyDeep; + + // Used only when user is waiting for approval to join via link + isTemporary?: boolean; + temporaryMemberCount?: number; + + // Legacy field, mapped to above in getConversation() + unblurredAvatarPath?: string; + + // remoteAvatarUrl + remoteAvatarUrl?: string; + + // Only used during backup integration tests. After import, our data model merges + // Contact and Chat frames from a backup, and we will then by default export both, even + // if the Chat frame was not imported. That's fine in normal usage, but breaks + // integration tests that aren't expecting to see a Chat frame on export that was not + // there on import. + test_chatFrameImportedFromBackup?: boolean; +}; + +// Copied from cleanup.preload.ts +const GENERIC_CLEANUP_FIELDS: Partial = { + draftChanged: undefined, + draftAttachments: undefined, + draftBodyRanges: undefined, + draftIsViewOnce: undefined, + draftTimestamp: undefined, + + inbox_position: undefined, + + lastMessageDeletedForEveryone: undefined, + lastMessageDeletedForEveryoneByAdminAci: undefined, + lastMessageAuthorAci: undefined, + lastMessage: undefined, + lastMessageBodyRanges: undefined, + lastMessagePrefix: undefined, + lastMessageAuthor: undefined, + lastMessageStatus: undefined, + lastMessageReceivedAt: undefined, + lastMessageReceivedAtMs: undefined, + + markedUnread: undefined, + messageCount: undefined, + messageCountBeforeMessageRequests: undefined, + messageRequestResponseType: undefined, + + messagesDeleted: true, + + quotedMessageId: undefined, + + sentMessageCount: undefined, + + timestamp: undefined, + unreadCount: undefined, + unreadMentionsCount: undefined, + + active_at: undefined, + draft: undefined, + draftEditMessage: undefined, + + isArchived: undefined, + + pendingUniversalTimer: undefined, +}; + +const GROUP_CLEANUP_FIELDS: Partial = { + addedBy: undefined, + + color: undefined, + colorFromPrimary: undefined, + conversationColor: undefined, + customColor: undefined, + customColorId: undefined, + + wallpaperPhotoPointerBase64: undefined, + wallpaperPreset: undefined, + dimWallpaperInDarkMode: undefined, + autoBubbleColor: undefined, + + hideStory: undefined, + + isReported: undefined, + name: undefined, + + profileSharing: undefined, + + pendingUniversalTimer: undefined, + pendingRemovedContactNotification: undefined, + reportingToken: undefined, + + left: undefined, + storySendMode: undefined, + groupVerifiedNameHash: undefined, + + members: undefined, + derivedGroupV2Id: undefined, + + secretParams: undefined, + publicParams: undefined, + revision: undefined, + senderKeyInfo: undefined, + + accessControl: undefined, + announcementsOnly: undefined, + terminated: undefined, + avatar: undefined, + avatars: undefined, + description: undefined, + expireTimer: undefined, + expireTimerVersion: 1, + membersV2: undefined, + pendingMembersV2: undefined, + pendingAdminApprovalV2: undefined, + bannedMembersV2: undefined, + groupInviteLinkPassword: undefined, + previousGroupV1Id: undefined, + previousGroupV1Members: undefined, + acknowledgedGroupNameCollisions: undefined, + + isTemporary: undefined, + temporaryMemberCount: undefined, + + unblurredAvatarPath: undefined, + + remoteAvatarUrl: undefined, +}; diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index 412900f01d..68e1721650 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -150,6 +150,7 @@ import updateToSchemaVersion1700 from './1700-trim-profile-names.std.ts'; import updateToSchemaVersion1710 from './1710-emoji-skin-tone-default.std.ts'; import updateToSchemaVersion1720 from './1720-update-recent-emoji.std.ts'; import updateToSchemaVersion1730 from './1730-protected-attachments-dedupe-token.std.ts'; +import updateToSchemaVersion1740 from './1740-cleanup-groups.node.ts'; import { DataWriter } from '../Server.node.ts'; import { strictAssert } from '../../util/assert.std.ts'; @@ -1465,7 +1466,8 @@ export type SchemaUpdateType = Readonly<{ update: ( db: WritableDB, logger: LoggerType, - startingVersion: number + startingVersion: number, + data: { userDataPath: string } ) => void | 'vacuum'; }>; @@ -1662,6 +1664,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1710, update: updateToSchemaVersion1710 }, { version: 1720, update: updateToSchemaVersion1720 }, { version: 1730, update: updateToSchemaVersion1730 }, + { version: 1740, update: updateToSchemaVersion1740 }, ]; class DBVersionFromFutureError extends Error { @@ -1693,7 +1696,11 @@ function enableFTS5SecureDelete(db: Database, logger: LoggerType): void { } } -export function updateSchema(db: WritableDB, logger: LoggerType): void { +export function updateSchema( + db: WritableDB, + logger: LoggerType, + data: { userDataPath: string } +): void { const sqliteVersion = getSQLiteVersion(db); const sqlcipherVersion = getSQLCipherVersion(db); const startingVersion = getUserVersion(db); @@ -1745,7 +1752,7 @@ export function updateSchema(db: WritableDB, logger: LoggerType): void { } const schemaLogger = logger.child(`updateSchema(${version})`); - const result = update(db, schemaLogger, startingVersion); + const result = update(db, schemaLogger, startingVersion, data); if (result === 'vacuum') { schemaLogger.info('success, needs vacuum'); diff --git a/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts b/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts index 6b191367c8..88f753e476 100644 --- a/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts +++ b/ts/test-electron/cleanupOrphanedAttachments_test.preload.ts @@ -148,6 +148,24 @@ describe('cleanupOrphanedAttachments', () => { assert.sameDeepMembers(listFiles('download'), []); }); + it('does not delete conversation draft attachments', async () => { + await writeFiles(2, 'draft'); + await writeFiles(2, 'attachment'); + + await DataWriter.saveConversation({ + id: generateUuid(), + type: 'private', + version: 2, + expireTimerVersion: 2, + draftAttachments: [{ path: 'draft0' }, { path: 'draft1' }], + }); + + await DataWriter.cleanupOrphanedAttachments({ _block: true }); + + assert.sameDeepMembers(listFiles('draft'), ['draft0', 'draft1']); + assert.sameDeepMembers(listFiles('attachment'), []); + }); + it('does not delete conversation avatar and profileAvatar paths', async () => { await writeFiles(6, 'attachment'); diff --git a/ts/test-node/sql/helpers.node.ts b/ts/test-node/sql/helpers.node.ts index fc71bc1c86..324a8cc7a7 100644 --- a/ts/test-node/sql/helpers.node.ts +++ b/ts/test-node/sql/helpers.node.ts @@ -1,6 +1,8 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { cwd } from 'node:process'; + import lodash from 'lodash'; import SQL from '@signalapp/sqlcipher'; @@ -17,7 +19,11 @@ export function createDB(): WritableDB { return db; } -export function updateToVersion(db: WritableDB, version: number): void { +export function updateToVersion( + db: WritableDB, + version: number, + data: { userDataPath: string } = { userDataPath: cwd() } +): void { const startVersion = db.pragma('user_version', { simple: true }) as number; if (startVersion === version) { return; @@ -34,7 +40,7 @@ export function updateToVersion(db: WritableDB, version: number): void { } db.transaction(() => { - update(db, silentLogger, startVersion); + update(db, silentLogger, startVersion, data); db.pragma(`user_version = ${version}`); })(); diff --git a/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts b/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts index 53745ff7d4..201182bb03 100644 --- a/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts +++ b/ts/test-node/sql/incrementMessagesMigrationAttempts_test.node.ts @@ -1,6 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { cwd } from 'node:process'; import { assert } from 'chai'; import type { WritableDB } from '../../sql/Interface.std.ts'; @@ -15,7 +16,7 @@ describe('SQL/incrementMessagesMigrationAttempts', () => { beforeEach(() => { db = createDB(); - setupTests(db); + setupTests(db, { userDataPath: cwd() }); }); afterEach(() => { diff --git a/ts/test-node/sql/migrateConversationMessages_test.node.ts b/ts/test-node/sql/migrateConversationMessages_test.node.ts index 308c50e50d..a4779004a3 100644 --- a/ts/test-node/sql/migrateConversationMessages_test.node.ts +++ b/ts/test-node/sql/migrateConversationMessages_test.node.ts @@ -1,6 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { cwd } from 'node:process'; import { assert } from 'chai'; import type { WritableDB } from '../../sql/Interface.std.ts'; @@ -15,7 +16,7 @@ describe('SQL/migrateConversationMessages', () => { beforeEach(() => { db = createDB(); - setupTests(db); + setupTests(db, { userDataPath: cwd() }); }); afterEach(() => { diff --git a/ts/test-node/sql/migration_1740_test.node.ts b/ts/test-node/sql/migration_1740_test.node.ts new file mode 100644 index 0000000000..ebeb149674 --- /dev/null +++ b/ts/test-node/sql/migration_1740_test.node.ts @@ -0,0 +1,343 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import fsExtra from 'fs-extra'; + +import { + createDB, + getTableData, + insertData, + updateToVersion, +} from './helpers.node.ts'; + +import type { WritableDB } from '../../sql/Interface.std.ts'; +import { + createAbsolutePathGetter, + type ConversationAttributesType, +} from '../../sql/migrations/1740-cleanup-groups.node.ts'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { mkdtempSync, readdirSync, rmdirSync } from 'node:fs'; +import { missingCaseError } from '../../util/missingCaseError.std.ts'; +import { + getAttachmentsPath, + getAvatarsPath, + getDraftPath, +} from '../../../app/attachments.node.ts'; + +const { emptyDir, ensureFile } = fsExtra; + +type TestAttachmentTypes = 'attachment' | 'avatar' | 'draft'; + +describe('SQL/updateToSchemaVersion1740', () => { + let db: WritableDB; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'Signal')); + db = createDB(); + updateToVersion(db, 1730, { userDataPath: tempDir }); + }); + + afterEach(async () => { + db.close(); + await emptyDir(tempDir); + rmdirSync(tempDir); + }); + + it('cleans up only left/terminated and deleted groups', async () => { + const attachmentsPath = getAttachmentsPath(tempDir); + const avatarsPath = getAvatarsPath(tempDir); + const draftPath = getDraftPath(tempDir); + + const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath); + const getAbsoluteAvatarPath = createAbsolutePathGetter(avatarsPath); + const getAbsoluteDraftPath = createAbsolutePathGetter(draftPath); + + function getAbsolutePath(path: string, type: TestAttachmentTypes) { + switch (type) { + case 'attachment': + return getAbsoluteAttachmentPath(path); + case 'avatar': + return getAbsoluteAvatarPath(path); + case 'draft': + return getAbsoluteDraftPath(path); + default: + throw missingCaseError(type); + } + } + + async function writeFile(path: string, type: TestAttachmentTypes) { + await ensureFile(getAbsolutePath(path, type)); + } + + async function writeFiles(num: number, type: TestAttachmentTypes) { + for (let i = 0; i < num; i += 1) { + // oxlint-disable-next-line no-await-in-loop + await writeFile(`${type}${i}`, type); + } + } + + function listFiles(type: TestAttachmentTypes): Array { + return readdirSync(dirname(getAbsolutePath('not used', type))); + } + + await writeFiles(3, 'attachment'); + await writeFiles(6, 'draft'); + await writeFiles(6, 'avatar'); + + const now = Date.now(); + const initialData: Array<{ + id: string; + type: string; + name: string; + members?: string; + active_at?: number; + expireTimerVersion: number; + json: Partial; + }> = [ + { + id: 'c0', + type: 'private', + name: 'John', + expireTimerVersion: 1, + json: { + id: 'c0', + type: 'private', + timestamp: now, + name: 'John', + }, + }, + { + id: 'c1', + type: 'private', + name: 'John, deleted', + expireTimerVersion: 1, + json: { + id: 'c1', + type: 'private', + timestamp: now, + name: 'John', + messagesDeleted: true, + }, + }, + { + id: 'c2', + type: 'group', + name: 'GV1 group', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c2', + type: 'group', + groupId: '249qw4kh23492p34', + timestamp: now, + name: 'GV1 group', + members: ['1', '2', '3'], + }, + }, + { + id: 'c3', + type: 'group', + active_at: now, + name: 'GV2 group with activeAt', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c3', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group with activeAt', + timestamp: now, + active_at: now, + members: ['1', '2', '3'], + }, + }, + { + id: 'c4', + type: 'group', + name: 'GV2 group with messagesDeleted', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c4', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group with messagesDeleted', + timestamp: now, + members: ['1', '2', '3'], + }, + }, + { + id: 'c5', + type: 'group', + name: 'GV2 group, left', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c5', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group, left', + timestamp: now, + active_at: now, + members: ['1', '2', '3'], + left: true, + }, + }, + { + id: 'c6', + type: 'group', + active_at: now, + name: 'GV2 group, terminated', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c6', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group, terminated', + timestamp: now, + active_at: now, + members: ['1', '2', '3'], + terminated: true, + }, + }, + { + id: 'c7', + type: 'group', + name: 'GV2 group, deleted but not left or terminated', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c7', + type: 'group', + groupId: getRandomGroupId(), + avatar: { + path: 'attachment0', + }, + draftAttachments: [{ path: 'draft0' }, { screenshotPath: 'draft1' }], + avatars: [ + { id: 0, imagePath: 'avatar0' }, + { id: 1 }, + { id: 2, imagePath: 'avatar1' }, + ], + groupVersion: 2, + name: 'GV2 group, deleted but not left or terminated', + timestamp: now, + messagesDeleted: true, + members: ['1', '2', '3'], + }, + }, + { + id: 'c8', + type: 'group', + name: 'GV2 group, left and deleted - will be erased', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c8', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group, left and deleted - will be erased', + timestamp: now, + avatar: { + path: 'attachment1', + }, + draftAttachments: [{ path: 'draft2' }, { screenshotPath: 'draft3' }], + avatars: [ + { id: 0, imagePath: 'avatar2' }, + { id: 1 }, + { id: 2, imagePath: 'avatar3' }, + ], + messagesDeleted: true, + members: ['1', '2', '3'], + left: true, + }, + }, + { + id: 'c9', + type: 'group', + name: 'GV2 group, terminated and deleted - will be erased', + members: '1 2 3', + expireTimerVersion: 1, + json: { + id: 'c9', + type: 'group', + groupId: getRandomGroupId(), + groupVersion: 2, + name: 'GV2 group, terminated and deleted - will be erased', + timestamp: now, + avatar: { + path: 'attachment2', + }, + draftAttachments: [{ path: 'draft4' }, { screenshotPath: 'draft5' }], + avatars: [ + { id: 0, imagePath: 'avatar4' }, + { id: 1 }, + { id: 2, imagePath: 'avatar5' }, + ], + messagesDeleted: true, + members: ['1', '2', '3'], + terminated: true, + }, + }, + ]; + + insertData(db, 'conversations', initialData); + + updateToVersion(db, 1740, { userDataPath: tempDir }); + + assert.deepStrictEqual(getTableData(db, 'conversations'), [ + initialData[0], + initialData[1], + initialData[2], + initialData[3], + initialData[4], + initialData[5], + initialData[6], + initialData[7], + { + id: 'c8', + type: 'group', + expireTimerVersion: 1, + json: { + id: 'c8', + type: 'group', + groupId: initialData[8]?.json.groupId, + groupVersion: 2, + messagesDeleted: true, + expireTimerVersion: 1, + }, + }, + { + id: 'c9', + type: 'group', + expireTimerVersion: 1, + json: { + id: 'c9', + type: 'group', + groupId: initialData[9]?.json.groupId, + groupVersion: 2, + messagesDeleted: true, + expireTimerVersion: 1, + }, + }, + ]); + + assert.deepEqual(listFiles('attachment'), ['attachment0']); + assert.deepEqual(listFiles('draft'), ['draft0', 'draft1']); + assert.deepEqual(listFiles('avatar'), ['avatar0', 'avatar1']); + }); +}); + +function getRandomGroupId() { + return randomBytes(32).toBase64(); +} diff --git a/ts/test-node/sql/server/pinnedMessages_test.node.ts b/ts/test-node/sql/server/pinnedMessages_test.node.ts index e89edb55fa..f88b609d12 100644 --- a/ts/test-node/sql/server/pinnedMessages_test.node.ts +++ b/ts/test-node/sql/server/pinnedMessages_test.node.ts @@ -1,6 +1,8 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import assert from 'node:assert/strict'; +import { cwd } from 'node:process'; + import type { WritableDB } from '../../../sql/Interface.std.ts'; import { setupTests } from '../../../sql/Server.node.ts'; import type { AppendPinnedMessageResult } from '../../../sql/server/pinnedMessages.std.ts'; @@ -50,7 +52,7 @@ describe('sql/server/pinnedMessages', () => { beforeEach(() => { db = createDB(); - setupTests(db); + setupTests(db, { userDataPath: cwd() }); setupData(db); }); diff --git a/ts/util/cleanup.preload.ts b/ts/util/cleanup.preload.ts index 1b45da7dde..35b87e0017 100644 --- a/ts/util/cleanup.preload.ts +++ b/ts/util/cleanup.preload.ts @@ -5,7 +5,10 @@ import PQueue from 'p-queue'; import { batch } from 'react-redux'; import { pick } from 'lodash'; -import type { MessageAttributesType } from '../model-types.d.ts'; +import type { + ConversationAttributesType, + MessageAttributesType, +} from '../model-types.d.ts'; import { MessageModel } from '../models/messages.preload.ts'; import * as Errors from '../types/errors.std.ts'; @@ -31,7 +34,9 @@ import { getFilePathsReferencedByMessage, } from './messageFilePaths.std.ts'; import { + deleteAvatar, deleteDownloadFile, + deleteDraftFile, maybeDeleteAttachmentFile, } from './migrations.preload.ts'; import { hydrateStoryContext } from './hydrateStoryContext.preload.ts'; @@ -43,6 +48,11 @@ import { type EraseMessageReasonType, } from '../types/Message.std.ts'; import type { AttachmentType } from '../types/Attachment.std.ts'; +import { + getExternalDraftFilesForConversation, + getExternalAvatarFilesForConversation, + getExternalAvatarDraftsForConversation, +} from './conversationFilePaths.std.ts'; const log = createLogger('cleanup'); @@ -311,3 +321,151 @@ export async function cleanupAttachmentFiles( ); await Promise.all([...result.externalDownloads].map(deleteDownloadFile)); } + +export async function safeCleanupDraftFiles( + conversation: ConversationAttributesType +): Promise { + const result = getExternalDraftFilesForConversation(conversation); + await Promise.all( + result.map(async (relativeFile, index) => { + try { + await deleteDraftFile(relativeFile); + } catch (error) { + log.error( + `safeCleanupDraftFiles: Failed to delete draft at index ${index}`, + Errors.toLogFormat(error) + ); + } + }) + ); +} + +export async function safeCleanupAvatarFiles( + conversation: ConversationAttributesType +): Promise { + const result = getExternalAvatarFilesForConversation(conversation); + await Promise.all(result.map(maybeDeleteAttachmentFile)); +} + +export async function safeCleanupAvatarDraftFiles( + conversation: ConversationAttributesType +): Promise { + const result = getExternalAvatarDraftsForConversation(conversation); + await Promise.all( + result.map(async (relativeFile, index) => { + try { + await deleteAvatar(relativeFile); + } catch (error) { + log.error( + `safeCleanupAvatarDraftFiles: Failed to delete avatar draft at index ${index}`, + Errors.toLogFormat(error) + ); + } + }) + ); +} + +export const GENERIC_CLEANUP_FIELDS: Partial = { + draftChanged: undefined, + draftAttachments: undefined, + draftBodyRanges: undefined, + draftIsViewOnce: undefined, + draftTimestamp: undefined, + + inbox_position: undefined, + + lastMessageDeletedForEveryone: undefined, + lastMessageDeletedForEveryoneByAdminAci: undefined, + lastMessageAuthorAci: undefined, + lastMessage: undefined, + lastMessageBodyRanges: undefined, + lastMessagePrefix: undefined, + lastMessageAuthor: undefined, + lastMessageStatus: undefined, + lastMessageReceivedAt: undefined, + lastMessageReceivedAtMs: undefined, + + markedUnread: undefined, + messageCount: undefined, + messageCountBeforeMessageRequests: undefined, + messageRequestResponseType: undefined, + + messagesDeleted: true, + + quotedMessageId: undefined, + + sentMessageCount: undefined, + + timestamp: undefined, + unreadCount: undefined, + unreadMentionsCount: undefined, + + active_at: undefined, + draft: undefined, + draftEditMessage: undefined, + + isArchived: undefined, + + pendingUniversalTimer: undefined, + + avatars: undefined, +}; + +export const GROUP_CLEANUP_FIELDS: Partial = { + addedBy: undefined, + + color: undefined, + colorFromPrimary: undefined, + conversationColor: undefined, + customColor: undefined, + customColorId: undefined, + + wallpaperPhotoPointerBase64: undefined, + wallpaperPreset: undefined, + dimWallpaperInDarkMode: undefined, + autoBubbleColor: undefined, + + hideStory: undefined, + + isReported: undefined, + name: undefined, + + pendingUniversalTimer: undefined, + pendingRemovedContactNotification: undefined, + reportingToken: undefined, + + left: undefined, + storySendMode: undefined, + groupVerifiedNameHash: undefined, + + members: undefined, + derivedGroupV2Id: undefined, + + secretParams: undefined, + publicParams: undefined, + revision: undefined, + senderKeyInfo: undefined, + + accessControl: undefined, + announcementsOnly: undefined, + avatar: undefined, + avatars: undefined, + description: undefined, + expireTimer: undefined, + expireTimerVersion: 1, + membersV2: undefined, + pendingMembersV2: undefined, + pendingAdminApprovalV2: undefined, + bannedMembersV2: undefined, + groupInviteLinkPassword: undefined, + previousGroupV1Id: undefined, + previousGroupV1Members: undefined, + acknowledgedGroupNameCollisions: undefined, + + isTemporary: undefined, + temporaryMemberCount: undefined, + + unblurredAvatarPath: undefined, + + remoteAvatarUrl: undefined, +}; diff --git a/ts/util/conversationFilePaths.std.ts b/ts/util/conversationFilePaths.std.ts new file mode 100644 index 0000000000..336c9004cb --- /dev/null +++ b/ts/util/conversationFilePaths.std.ts @@ -0,0 +1,60 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationAttributesType } from '../model-types.d.ts'; + +export function getExternalAvatarFilesForConversation( + conversation: Pick +): Array { + const { avatar, profileAvatar } = conversation; + const files: Array = []; + + if (avatar && avatar.path) { + files.push(avatar.path); + } + + if (profileAvatar && profileAvatar.path) { + files.push(profileAvatar.path); + } + + return files; +} + +export function getExternalAvatarDraftsForConversation( + conversation: Pick +): Array { + const { avatars } = conversation; + const files: Array = []; + + (avatars || []).forEach(item => { + if (item.imagePath) { + files.push(item.imagePath); + } + }); + + return files; +} + +export function getExternalDraftFilesForConversation( + conversation: Pick +): Array { + const draftAttachments = conversation.draftAttachments || []; + const files: Array = []; + + (draftAttachments || []).forEach(attachment => { + if (attachment.pending) { + return; + } + + const { path: file, screenshotPath } = attachment; + if (file) { + files.push(file); + } + + if (screenshotPath) { + files.push(screenshotPath); + } + }); + + return files; +}