Data storage improvements after deleting and leaving a group

Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg
2026-07-22 04:38:31 +10:00
committed by GitHub
parent 5fb6ce2f89
commit 8efd06f9d8
19 changed files with 1253 additions and 69 deletions
+4
View File
@@ -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"
@@ -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 (
<DeleteMessagesConfirmationDialog
i18n={i18n}
onClose={action('onClose')}
onDestroyMessages={action('onDestroyMessages')}
areWeMember
/>
);
}
@@ -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 (
<AxoConfirmDialog.Root
@@ -260,6 +260,8 @@ export const ConversationHeader = memo(function ConversationHeader({
);
const isTerminated = Boolean(conversation.terminated);
const areWeMember =
conversation.type === 'group' && !isTerminated && !conversation.left;
const isMuted = isConversationMuted(conversation);
if (hasPanelShowing) {
@@ -291,6 +293,7 @@ export const ConversationHeader = memo(function ConversationHeader({
onClose={() => {
setHasDeleteMessagesConfirmation(false);
}}
areWeMember={areWeMember}
/>
)}
{hasLeaveGroupConfirmation && (
@@ -473,6 +473,7 @@ export function ConversationDetailsActions({
onClose={() => {
gGroupDelete(false);
}}
areWeMember={!left && !isGroupTerminated}
/>
)}
</>
@@ -293,6 +293,11 @@ export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationLis
i18n={i18n}
onDestroyMessages={handleDelete}
onClose={handleCloseConfirmDeleteDialog}
areWeMember={
conversation.type === 'group' &&
!conversation.left &&
!conversation.terminated
}
/>
)}
</>
+20 -10
View File
@@ -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<void> {
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 (
+4 -1
View File
@@ -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;
+11 -46
View File
@@ -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<ConversationType, 'avatar' | 'profileAvatar'>
): Array<string> {
const { avatar, profileAvatar } = conversation;
const files: Array<string> = [];
if (avatar && avatar.path) {
files.push(avatar.path);
}
if (profileAvatar && profileAvatar.path) {
files.push(profileAvatar.path);
}
return files;
}
function getExternalDraftFilesForConversation(
conversation: Pick<ConversationType, 'draftAttachments'>
): Array<string> {
const draftAttachments = conversation.draftAttachments || [];
const files: Array<string> = [];
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<string> {
jsonToObject(row.json)
);
conversations.forEach(conversation => {
const externalFiles = getExternalFilesForConversation(conversation);
const externalFiles = getExternalAvatarFilesForConversation(conversation);
externalFiles.forEach(file => result.add(file));
});
@@ -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<string>()
.map(json => jsonToObject<ConversationAttributesType>(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<ConversationAttributesType, 'avatar' | 'profileAvatar'>
): Array<string> {
const { avatar, profileAvatar } = conversation;
const files: Array<string> = [];
if (avatar && avatar.path) {
files.push(avatar.path);
}
if (profileAvatar && profileAvatar.path) {
files.push(profileAvatar.path);
}
return files;
}
function getExternalAvatarDraftsForConversation(
conversation: Pick<ConversationAttributesType, 'avatars'>
): Array<string> {
const { avatars } = conversation;
const files: Array<string> = [];
(avatars || []).forEach(item => {
if (item.imagePath) {
files.push(item.imagePath);
}
});
return files;
}
function getExternalDraftFilesForConversation(
conversation: Pick<ConversationAttributesType, 'draftAttachments'>
): Array<string> {
const draftAttachments = conversation.draftAttachments || [];
const files: Array<string> = [];
(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<typeof MIMETypeSchema>;
// 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<ArrayBuffer>;
}>;
// Copied from Avatar.std.ts
export type ContactAvatarType =
| ({
// Downloaded avatar
path: string;
url?: string;
hash?: string;
} & Partial<AddressableAttachmentType>)
| {
// Not-yet downloaded avatar
path?: undefined;
url: string;
hash?: string;
};
export type AvatarDataType = {
id: number | string;
buffer?: Uint8Array<ArrayBuffer>;
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<OpaqueType>;
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<string>;
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<Readonly<AvatarDataType>>;
description?: string;
expireTimer?: OpaqueType;
expireTimerVersion: number;
membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<OpaqueType>;
pendingAdminApprovalV2?: Array<OpaqueType>;
bannedMembersV2?: Array<OpaqueType>;
groupInviteLinkPassword?: string;
previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>;
acknowledgedGroupNameCollisions?: ReadonlyDeep<OpaqueType>;
// 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<ConversationAttributesType> = {
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<ConversationAttributesType> = {
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,
};
+10 -3
View File
@@ -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<SchemaUpdateType> = [
{ 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');
@@ -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');
+8 -2
View File
@@ -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}`);
})();
@@ -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(() => {
@@ -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(() => {
@@ -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<string> {
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<ConversationAttributesType>;
}> = [
{
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();
}
@@ -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);
});
+159 -1
View File
@@ -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<void> {
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<void> {
const result = getExternalAvatarFilesForConversation(conversation);
await Promise.all(result.map(maybeDeleteAttachmentFile));
}
export async function safeCleanupAvatarDraftFiles(
conversation: ConversationAttributesType
): Promise<void> {
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<ConversationAttributesType> = {
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<ConversationAttributesType> = {
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,
};
+60
View File
@@ -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<ConversationAttributesType, 'avatar' | 'profileAvatar'>
): Array<string> {
const { avatar, profileAvatar } = conversation;
const files: Array<string> = [];
if (avatar && avatar.path) {
files.push(avatar.path);
}
if (profileAvatar && profileAvatar.path) {
files.push(profileAvatar.path);
}
return files;
}
export function getExternalAvatarDraftsForConversation(
conversation: Pick<ConversationAttributesType, 'avatars'>
): Array<string> {
const { avatars } = conversation;
const files: Array<string> = [];
(avatars || []).forEach(item => {
if (item.imagePath) {
files.push(item.imagePath);
}
});
return files;
}
export function getExternalDraftFilesForConversation(
conversation: Pick<ConversationAttributesType, 'draftAttachments'>
): Array<string> {
const draftAttachments = conversation.draftAttachments || [];
const files: Array<string> = [];
(draftAttachments || []).forEach(attachment => {
if (attachment.pending) {
return;
}
const { path: file, screenshotPath } = attachment;
if (file) {
files.push(file);
}
if (screenshotPath) {
files.push(screenshotPath);
}
});
return files;
}