mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-07-25 15:36:55 +01:00
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:
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user