Normalize message attachments

This commit is contained in:
trevor-signal 2025-05-22 21:09:54 -04:00 committed by GitHub
parent 8d8e0329cf
commit d6e81eee11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2540 additions and 807 deletions

View file

@ -273,11 +273,13 @@ type DeleteOrphanedAttachmentsOptionsType = Readonly<{
type CleanupOrphanedAttachmentsOptionsType = Readonly<{
sql: MainSQL;
userDataPath: string;
_block?: boolean;
}>;
async function cleanupOrphanedAttachments({
sql,
userDataPath,
_block = false,
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
await deleteAllBadges({
userDataPath,
@ -304,8 +306,6 @@ async function cleanupOrphanedAttachments({
attachments: orphanedDraftAttachments,
});
// Delete orphaned attachments from conversations and messages.
const orphanedAttachments = new Set(await getAllAttachments(userDataPath));
console.log(
'cleanupOrphanedAttachments: found ' +
@ -319,21 +319,27 @@ async function cleanupOrphanedAttachments({
);
{
const attachments: Array<string> = await sql.sqlRead(
const conversationAttachments: Array<string> = await sql.sqlRead(
'getKnownConversationAttachments'
);
let missing = 0;
for (const known of attachments) {
let missingConversationAttachments = 0;
for (const known of conversationAttachments) {
if (!orphanedAttachments.delete(known)) {
missing += 1;
missingConversationAttachments += 1;
}
}
console.log(
`cleanupOrphanedAttachments: found ${attachments.length} conversation ` +
`attachments (${missing} missing), ${orphanedAttachments.size} remain`
`cleanupOrphanedAttachments: Got ${conversationAttachments.length} conversation attachments,` +
` ${orphanedAttachments.size} remain`
);
if (missingConversationAttachments > 0) {
console.warn(
`cleanupOrphanedAttachments: ${missingConversationAttachments} conversation attachments were not found on disk`
);
}
}
{
@ -347,20 +353,32 @@ async function cleanupOrphanedAttachments({
}
console.log(
`cleanupOrphanedAttachments: found ${downloads.length} downloads ` +
`(${missing} missing), ${orphanedDownloads.size} remain`
`cleanupOrphanedAttachments: found ${downloads.length} known downloads, ` +
`${orphanedDownloads.size} remain`
);
if (missing > 0) {
console.warn(
`cleanupOrphanedAttachments: ${missing} downloads were not found on disk`
);
}
}
// This call is intentionally not awaited. We block the app while running
// all fetches above to ensure that there are no in-flight attachments that
// are saved to disk, but not put into any message or conversation model yet.
deleteOrphanedAttachments({
const deletePromise = deleteOrphanedAttachments({
orphanedAttachments,
orphanedDownloads,
sql,
userDataPath,
});
if (_block) {
await deletePromise;
} else {
drop(deletePromise);
}
}
function deleteOrphanedAttachments({
@ -368,14 +386,14 @@ function deleteOrphanedAttachments({
orphanedDownloads,
sql,
userDataPath,
}: DeleteOrphanedAttachmentsOptionsType): void {
}: DeleteOrphanedAttachmentsOptionsType): Promise<void> {
// This function *can* throw.
async function runWithPossibleException(): Promise<void> {
let cursor: MessageAttachmentsCursorType | undefined;
let totalFound = 0;
let totalAttachmentsFound = 0;
let totalMissing = 0;
let totalDownloadsFound = 0;
let totalDownloadsMissing = 0;
try {
do {
let attachments: ReadonlyArray<string>;
@ -387,7 +405,7 @@ function deleteOrphanedAttachments({
cursor
));
totalFound += attachments.length;
totalAttachmentsFound += attachments.length;
totalDownloadsFound += downloads.length;
for (const known of attachments) {
@ -397,9 +415,7 @@ function deleteOrphanedAttachments({
}
for (const known of downloads) {
if (!orphanedDownloads.delete(known)) {
totalDownloadsMissing += 1;
}
orphanedDownloads.delete(known);
}
if (cursor === undefined) {
@ -418,11 +434,16 @@ function deleteOrphanedAttachments({
}
console.log(
`cleanupOrphanedAttachments: found ${totalFound} message ` +
`attachments, (${totalMissing} missing) ` +
`${orphanedAttachments.size} remain`
`cleanupOrphanedAttachments: ${totalAttachmentsFound} message ` +
`attachments; ${orphanedAttachments.size} remain`
);
if (totalMissing > 0) {
console.warn(
`cleanupOrphanedAttachments: ${totalMissing} message attachments were not found on disk`
);
}
await deleteAllAttachments({
userDataPath,
attachments: Array.from(orphanedAttachments),
@ -430,7 +451,6 @@ function deleteOrphanedAttachments({
console.log(
`cleanupOrphanedAttachments: found ${totalDownloadsFound} downloads ` +
`(${totalDownloadsMissing} missing) ` +
`${orphanedDownloads.size} remain`
);
await deleteAllDownloads({
@ -454,8 +474,7 @@ function deleteOrphanedAttachments({
}
}
// Intentionally not awaiting
void runSafe();
return runSafe();
}
let attachmentsDir: string | undefined;
@ -505,12 +524,19 @@ export function initialize({
rmSync(downloadsDir, { recursive: true, force: true });
});
ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => {
const start = Date.now();
await cleanupOrphanedAttachments({ sql, userDataPath: configDir });
const duration = Date.now() - start;
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
});
ipcMain.handle(
CLEANUP_ORPHANED_ATTACHMENTS_KEY,
async (_event, { _block }) => {
const start = Date.now();
await cleanupOrphanedAttachments({
sql,
userDataPath: configDir,
_block,
});
const duration = Date.now() - start;
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
}
);
ipcMain.handle(CLEANUP_DOWNLOADS_KEY, async () => {
const start = Date.now();

View file

@ -81,6 +81,7 @@ export type ReencryptedAttachmentV2 = {
localKey: string;
isReencryptableToSameDigest: boolean;
version: 2;
size: number;
};
export type ReencryptionInfo = {
@ -583,7 +584,7 @@ export async function decryptAttachmentV2ToSink(
export async function decryptAndReencryptLocally(
options: DecryptAttachmentOptionsType
): Promise<ReencryptedAttachmentV2> {
const { idForLogging } = options;
const { idForLogging, size } = options;
const logId = `reencryptAttachmentV2(${idForLogging})`;
// Create random output file
@ -622,6 +623,7 @@ export async function decryptAndReencryptLocally(
plaintextHash: result.plaintextHash,
isReencryptableToSameDigest: result.isReencryptableToSameDigest,
version: 2,
size,
};
} catch (error) {
log.error(

View file

@ -913,6 +913,7 @@ export function MixedContentTypes(args: Props): JSX.Element {
screenshot: {
height: 112,
width: 112,
size: 128000,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
path: 'originalpath',

View file

@ -244,7 +244,7 @@ ImageOnly.args = {
width: 100,
size: 100,
path: pngUrl,
objectUrl: pngUrl,
url: pngUrl,
},
},
};
@ -261,7 +261,7 @@ ImageAttachment.args = {
width: 100,
size: 100,
path: pngUrl,
objectUrl: pngUrl,
url: pngUrl,
},
},
};
@ -331,7 +331,7 @@ VideoOnly.args = {
width: 100,
size: 100,
path: pngUrl,
objectUrl: pngUrl,
url: pngUrl,
},
},
text: undefined,
@ -349,7 +349,7 @@ VideoAttachment.args = {
width: 100,
size: 100,
path: pngUrl,
objectUrl: pngUrl,
url: pngUrl,
},
},
};
@ -588,7 +588,7 @@ IsStoryReplyEmoji.args = {
width: 100,
size: 100,
path: pngUrl,
objectUrl: pngUrl,
url: pngUrl,
},
},
reactionEmoji: '🏋️',

View file

@ -33,7 +33,10 @@ import type { QuotedAttachmentType } from '../../model-types';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
export type QuotedAttachmentForUIType = QuotedAttachmentType &
export type QuotedAttachmentForUIType = Pick<
QuotedAttachmentType,
'contentType' | 'thumbnail' | 'fileName'
> &
Pick<AttachmentType, 'isVoiceMessage' | 'fileName' | 'textAttachment'>;
export type Props = {
@ -101,7 +104,7 @@ function getUrl(thumbnail?: ThumbnailType): string | undefined {
return;
}
return thumbnail.objectUrl || thumbnail.url;
return thumbnail.url;
}
function getTypeLabel({

View file

@ -46,7 +46,7 @@ import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { MIMEType } from '../types/MIME';
import { IMAGE_JPEG, type MIMEType } from '../types/MIME';
import { AttachmentDownloadSource } from '../sql/Interface';
import { drop } from '../util/drop';
import {
@ -64,6 +64,7 @@ import {
isPermanentlyUndownloadableWithoutBackfill,
} from './helpers/attachmentBackfill';
import { formatCountForLogging } from '../logging/formatCountForLogging';
import { strictAssert } from '../util/assert';
export { isPermanentlyUndownloadable };
@ -648,7 +649,10 @@ export async function runDownloadAttachmentJobInner({
attachmentWithThumbnail,
};
} catch (e) {
log.warn(`${logId}: error when trying to download thumbnail`);
log.warn(
`${logId}: error when trying to download thumbnail`,
Errors.toLogFormat(e)
);
}
}
@ -836,9 +840,16 @@ async function downloadBackupThumbnail({
},
});
const calculatedSize = downloadedThumbnail.size;
strictAssert(calculatedSize, 'size must be calculated for backup thumbnails');
const attachmentWithThumbnail = {
...attachment,
thumbnailFromBackup: downloadedThumbnail,
thumbnailFromBackup: {
contentType: IMAGE_JPEG,
...downloadedThumbnail,
size: calculatedSize,
},
};
return attachmentWithThumbnail;

View file

@ -1,5 +1,6 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import * as log from '../logging/log';
import * as Bytes from '../Bytes';
import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload';
@ -340,7 +341,7 @@ export async function addAttachmentToMessage(
if (thumbnail !== newThumbnail) {
handledInEditHistory = true;
}
return { ...item, thumbnail: newThumbnail };
return { ...item, thumbnail: omit(newThumbnail, 'thumbnail') };
}),
},
};
@ -362,7 +363,7 @@ export async function addAttachmentToMessage(
return {
...item,
thumbnail: maybeReplaceAttachment(thumbnail),
thumbnail: maybeReplaceAttachment(omit(thumbnail, 'thumbnail')),
};
}),
};

View file

@ -13,8 +13,8 @@ import { strictAssert } from '../util/assert';
import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { isQuoteAMatch, messageHasPaymentEvent } from './helpers';
import * as Errors from '../types/errors';
import { isDownloadable } from '../types/Attachment';
import type { MessageModel } from '../models/messages';
import { isDownloadable } from '../types/Attachment';
export type MinimalMessageCache = Readonly<{
findBySentAt(
@ -77,7 +77,7 @@ export const copyQuoteContentFromOriginal = async (
{ messageCache = window.MessageCache }: CopyQuoteOptionsType = {}
): Promise<void> => {
const { attachments } = quote;
const firstAttachment = attachments ? attachments[0] : undefined;
const quoteAttachment = attachments ? attachments[0] : undefined;
if (messageHasPaymentEvent(message.attributes)) {
// eslint-disable-next-line no-param-reassign
@ -125,7 +125,7 @@ export const copyQuoteContentFromOriginal = async (
// eslint-disable-next-line no-param-reassign
quote.bodyRanges = message.attributes.bodyRanges;
if (!firstAttachment || !firstAttachment.contentType) {
if (!quoteAttachment || !quoteAttachment.contentType) {
return;
}
@ -150,17 +150,17 @@ export const copyQuoteContentFromOriginal = async (
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
const { thumbnail: quotedThumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = {
...thumbnail,
if (quotedThumbnail && quotedThumbnail.path) {
quoteAttachment.thumbnail = {
...quotedThumbnail,
copied: true,
};
} else if (!firstAttachment.thumbnail || !isDownloadable(queryFirst)) {
firstAttachment.contentType = queryFirst.contentType;
firstAttachment.fileName = queryFirst.fileName;
firstAttachment.thumbnail = undefined;
} else if (!quoteAttachment.thumbnail || !isDownloadable(queryFirst)) {
quoteAttachment.contentType = queryFirst.contentType;
quoteAttachment.fileName = queryFirst.fileName;
quoteAttachment.thumbnail = undefined;
} else {
// there is a thumbnail, but the original message attachment has not been
// downloaded yet, so we leave the quote attachment as is for now
@ -168,19 +168,17 @@ export const copyQuoteContentFromOriginal = async (
}
if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (image && image.path) {
firstAttachment.thumbnail = {
...image,
const { image: quotedPreviewImage } = queryPreview[0];
if (quotedPreviewImage && quotedPreviewImage.path) {
quoteAttachment.thumbnail = {
...quotedPreviewImage,
copied: true,
};
}
}
if (sticker && sticker.data && sticker.data.path) {
firstAttachment.thumbnail = {
quoteAttachment.thumbnail = {
...sticker.data,
copied: true,
};

View file

@ -554,9 +554,6 @@ export async function handleDataMessage(
errors: [],
flags: dataMessage.flags,
giftBadge: initialMessage.giftBadge,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
isViewOnce: Boolean(dataMessage.isViewOnce),
mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => {
if (!BodyRange.isMention(bodyRange)) {

11
ts/model-types.d.ts vendored
View file

@ -15,11 +15,7 @@ import type { ReadStatus } from './messages/MessageReadStatus';
import type { SendStateByConversationId } from './messages/MessageSendState';
import type { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import type {
AttachmentDraftType,
AttachmentType,
ThumbnailType,
} from './types/Attachment';
import type { AttachmentDraftType, AttachmentType } from './types/Attachment';
import type { EmbeddedContactType } from './types/EmbeddedContact';
import { SignalService as Proto } from './protobuf';
import type { AvatarDataType, ContactAvatarType } from './types/Avatar';
@ -82,7 +78,7 @@ export type GroupMigrationType = {
export type QuotedAttachmentType = {
contentType: MIMEType;
fileName?: string;
thumbnail?: ThumbnailType;
thumbnail?: AttachmentType;
};
export type QuotedMessageType = {
@ -186,9 +182,6 @@ export type MessageAttributesType = {
expireTimer?: DurationInSeconds;
groupMigration?: GroupMigrationType;
group_update?: GroupV1Update;
hasAttachments?: boolean | 0 | 1;
hasFileAttachments?: boolean | 0 | 1;
hasVisualMediaAttachments?: boolean | 0 | 1;
mentionsMe?: boolean | 0 | 1;
isErased?: boolean;
isTapToViewInvalid?: boolean;

View file

@ -49,7 +49,6 @@ import type {
ItemType,
StoredItemType,
MessageType,
MessageTypeUnhydrated,
PreKeyIdType,
PreKeyType,
StoredPreKeyType,
@ -62,7 +61,6 @@ import type {
ClientOnlyReadableInterface,
ClientOnlyWritableInterface,
} from './Interface';
import { hydrateMessage } from './hydration';
import type { MessageAttributesType } from '../model-types';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
@ -546,7 +544,6 @@ function handleSearchMessageJSON(
messages: Array<ServerSearchResultMessageType>
): Array<ClientSearchResultMessageType> {
return messages.map<ClientSearchResultMessageType>(message => {
const parsedMessage = hydrateMessage(message);
assertDev(
message.ftsSnippet ?? typeof message.mentionStart === 'number',
'Neither ftsSnippet nor matching mention returned from message search'
@ -554,7 +551,7 @@ function handleSearchMessageJSON(
const snippet =
message.ftsSnippet ??
generateSnippetAroundMention({
body: parsedMessage.body || '',
body: message.body || '',
mentionStart: message.mentionStart ?? 0,
mentionLength: message.mentionLength ?? 1,
});
@ -562,7 +559,7 @@ function handleSearchMessageJSON(
return {
// Empty array is a default value. `message.json` has the real field
bodyRanges: [],
...parsedMessage,
...message,
snippet,
};
});
@ -629,15 +626,17 @@ async function saveMessages(
forceSave,
ourAci,
postSaveUpdates,
_testOnlyAvoidNormalizingAttachments,
}: {
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
_testOnlyAvoidNormalizingAttachments?: boolean;
}
): Promise<Array<string>> {
const result = await writableChannel.saveMessages(
arrayOfMessages.map(message => _cleanMessageData(message)),
{ forceSave, ourAci }
{ forceSave, ourAci, _testOnlyAvoidNormalizingAttachments }
);
drop(postSaveUpdates?.());
@ -730,19 +729,13 @@ async function removeMessages(
await writableChannel.removeMessages(messageIds);
}
function handleMessageJSON(
messages: Array<MessageTypeUnhydrated>
): Array<MessageType> {
return messages.map(message => hydrateMessage(message));
}
async function getNewerMessagesByConversation(
options: AdjacentMessagesByConversationOptionsType
): Promise<Array<MessageType>> {
const messages =
await readableChannel.getNewerMessagesByConversation(options);
return handleMessageJSON(messages);
return messages;
}
async function getRecentStoryReplies(
@ -754,7 +747,7 @@ async function getRecentStoryReplies(
options
);
return handleMessageJSON(messages);
return messages;
}
async function getOlderMessagesByConversation(
@ -763,7 +756,7 @@ async function getOlderMessagesByConversation(
const messages =
await readableChannel.getOlderMessagesByConversation(options);
return handleMessageJSON(messages);
return messages;
}
async function getConversationRangeCenteredOnMessage(
@ -772,11 +765,7 @@ async function getConversationRangeCenteredOnMessage(
const result =
await readableChannel.getConversationRangeCenteredOnMessage(options);
return {
...result,
older: handleMessageJSON(result.older),
newer: handleMessageJSON(result.newer),
};
return result;
}
async function removeMessagesInConversation(
@ -832,9 +821,13 @@ async function saveAttachmentDownloadJob(
// Other
async function cleanupOrphanedAttachments(): Promise<void> {
async function cleanupOrphanedAttachments({
_block = false,
}: {
_block?: boolean;
} = {}): Promise<void> {
try {
await invokeWithTimeout(CLEANUP_ORPHANED_ATTACHMENTS_KEY);
await invokeWithTimeout(CLEANUP_ORPHANED_ATTACHMENTS_KEY, { _block });
} catch (error) {
log.warn(
'sql/Client: cleanupOrphanedAttachments failure',
@ -859,9 +852,12 @@ async function removeOtherData(): Promise<void> {
]);
}
async function invokeWithTimeout(name: string): Promise<void> {
async function invokeWithTimeout(
name: string,
...args: Array<unknown>
): Promise<void> {
return createTaskWithTimeout(
() => ipc.invoke(name),
() => ipc.invoke(name, ...args),
`callChannel call to ${name}`
)();
}

View file

@ -37,7 +37,10 @@ import type {
CallLinkType,
DefunctCallLinkType,
} from '../types/CallLink';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type {
AttachmentDownloadJobType,
AttachmentDownloadJobTypeType,
} from '../types/AttachmentDownload';
import type {
GroupSendEndorsementsData,
GroupSendMemberEndorsementRecord,
@ -46,6 +49,7 @@ import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { GifType } from '../components/fun/panels/FunPanelGifs';
import type { NotificationProfileType } from '../types/NotificationProfile';
import { strictAssert } from '../util/assert';
export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never };
@ -217,7 +221,10 @@ export type StoredPreKeyType = PreKeyType & {
privateKey: string;
publicKey: string;
};
export type ServerSearchResultMessageType = MessageTypeUnhydrated & {
export type ServerSearchResultMessageType = MessageType &
ServerMessageSearchResultType;
export type ServerMessageSearchResultType = {
// If the FTS matches text in message.body, snippet will be populated
ftsSnippet: string | null;
@ -537,6 +544,136 @@ export enum AttachmentDownloadSource {
BACKFILL = 'backfill',
}
export const MESSAGE_ATTACHMENT_COLUMNS = [
'messageId',
'conversationId',
'sentAt',
'attachmentType',
'orderInMessage',
'editHistoryIndex',
'clientUuid',
'size',
'contentType',
'path',
'localKey',
'plaintextHash',
'caption',
'fileName',
'blurHash',
'height',
'width',
'digest',
'key',
'iv',
'flags',
'downloadPath',
'transitCdnKey',
'transitCdnNumber',
'transitCdnUploadTimestamp',
'backupMediaName',
'backupCdnNumber',
'incrementalMac',
'incrementalMacChunkSize',
'isReencryptableToSameDigest',
'reencryptionIv',
'reencryptionKey',
'reencryptionDigest',
'thumbnailPath',
'thumbnailSize',
'thumbnailContentType',
'thumbnailLocalKey',
'thumbnailVersion',
'screenshotPath',
'screenshotSize',
'screenshotContentType',
'screenshotLocalKey',
'screenshotVersion',
'backupThumbnailPath',
'backupThumbnailSize',
'backupThumbnailContentType',
'backupThumbnailLocalKey',
'backupThumbnailVersion',
'storyTextAttachmentJson',
'localBackupPath',
'isCorrupted',
'backfillError',
'error',
'wasTooBig',
'copiedFromQuotedAttachment',
'version',
'pending',
] as const satisfies Array<keyof MessageAttachmentDBType>;
export type MessageAttachmentDBType = {
messageId: string;
attachmentType: AttachmentDownloadJobTypeType;
orderInMessage: number;
editHistoryIndex: number | null;
conversationId: string;
sentAt: number;
clientUuid: string | null;
size: number;
contentType: string;
path: string | null;
plaintextHash: string | null;
downloadPath: string | null;
caption: string | null;
blurHash: string | null;
width: number | null;
height: number | null;
flags: number | null;
key: string | null;
iv: string | null;
digest: string | null;
fileName: string | null;
incrementalMac: string | null;
incrementalMacChunkSize: number | null;
localKey: string | null;
version: 1 | 2 | null;
transitCdnKey: string | null;
transitCdnNumber: number | null;
transitCdnUploadTimestamp: number | null;
backupMediaName: string | null;
backupCdnNumber: number | null;
thumbnailPath: string | null;
thumbnailSize: number | null;
thumbnailContentType: string | null;
thumbnailLocalKey: string | null;
thumbnailVersion: 1 | 2 | null;
screenshotPath: string | null;
screenshotSize: number | null;
screenshotContentType: string | null;
screenshotLocalKey: string | null;
screenshotVersion: 1 | 2 | null;
backupThumbnailPath: string | null;
backupThumbnailSize: number | null;
backupThumbnailContentType: string | null;
backupThumbnailLocalKey: string | null;
backupThumbnailVersion: 1 | 2 | null;
reencryptionIv: string | null;
reencryptionKey: string | null;
reencryptionDigest: string | null;
storyTextAttachmentJson: string | null;
localBackupPath: string | null;
isCorrupted: 1 | 0 | null;
backfillError: 1 | 0 | null;
error: 1 | 0 | null;
wasTooBig: 1 | 0 | null;
pending: 1 | 0 | null;
isReencryptableToSameDigest: 1 | 0 | null;
copiedFromQuotedAttachment: 1 | 0 | null;
};
// Test to make sure that MESSAGE_ATTACHMENT_COLUMNS &
// MessageAttachmentDBReferenceType remain in sync!
const testDBRefTypeMatchesColumnNames = true as unknown as [
keyof MessageAttachmentDBType,
] satisfies [(typeof MESSAGE_ATTACHMENT_COLUMNS)[number]];
strictAssert(
testDBRefTypeMatchesColumnNames,
'attachment_columns must match DB fields type'
);
type ReadableInterface = {
close: () => void;
@ -744,6 +881,9 @@ type ReadableInterface = {
getStatisticsForLogging(): Record<string, string>;
getSizeOfPendingBackupAttachmentDownloadJobs(): number;
getAttachmentReferencesForMessages: (
messageIds: Array<string>
) => Array<MessageAttachmentDBType>;
};
type WritableInterface = {
@ -866,6 +1006,7 @@ type WritableInterface = {
) => void;
_removeAllReactions: () => void;
_removeAllMessages: () => void;
_removeMessage: (id: string) => void;
incrementMessagesMigrationAttempts: (
messageIds: ReadonlyArray<string>
) => void;
@ -1072,16 +1213,16 @@ export type ServerReadableDirectInterface = ReadableInterface & {
getRecentStoryReplies(
storyId: string,
options?: GetRecentStoryRepliesOptionsType
): Array<MessageTypeUnhydrated>;
): Array<MessageType>;
getOlderMessagesByConversation: (
options: AdjacentMessagesByConversationOptionsType
) => Array<MessageTypeUnhydrated>;
) => Array<MessageType>;
getNewerMessagesByConversation: (
options: AdjacentMessagesByConversationOptionsType
) => Array<MessageTypeUnhydrated>;
) => Array<MessageType>;
getConversationRangeCenteredOnMessage: (
options: AdjacentMessagesByConversationOptionsType
) => GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated>;
) => GetConversationRangeCenteredOnMessageResultType<MessageType>;
getIdentityKeyById: (
id: IdentityKeyIdType
@ -1141,7 +1282,11 @@ export type ServerWritableDirectInterface = WritableInterface & {
) => string;
saveMessages: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
options: {
forceSave?: boolean;
ourAci: AciString;
_testOnlyAvoidNormalizingAttachments?: boolean;
}
) => Array<string>;
saveMessagesIndividually: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
@ -1241,6 +1386,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
_testOnlyAvoidNormalizingAttachments?: boolean;
}
) => string;
saveMessages: (
@ -1249,6 +1395,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
_testOnlyAvoidNormalizingAttachments?: boolean;
}
) => Array<string>;
saveMessagesIndividually: (
@ -1311,7 +1458,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
}
) => void;
removeOtherData: () => void;
cleanupOrphanedAttachments: () => void;
cleanupOrphanedAttachments: (options?: { _block: boolean }) => void;
ensureFilePermissions: () => void;
}>;

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { groupBy } from 'lodash';
import type { ReadStatus } from '../messages/MessageReadStatus';
import type { SeenStatus } from '../MessageSeenStatus';
import type { ServiceIdString } from '../types/ServiceId';
import { dropNull } from '../util/dropNull';
import { dropNull, shallowDropNull } from '../util/dropNull';
/* eslint-disable camelcase */
@ -12,7 +13,23 @@ import type {
MessageTypeUnhydrated,
MessageType,
MESSAGE_COLUMNS,
ReadableDB,
MessageAttachmentDBType,
} from './Interface';
import {
batchMultiVarQuery,
convertOptionalIntegerToBoolean,
jsonToObject,
sql,
sqlJoin,
} from './util';
import type { AttachmentType } from '../types/Attachment';
import { IMAGE_JPEG, stringToMIMEType } from '../types/MIME';
import { strictAssert } from '../util/assert';
import { sqlLogger } from './sqlLogger';
import type { MessageAttributesType } from '../model-types';
export const ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX = -1;
function toBoolean(value: number | null): boolean | undefined {
if (value == null) {
@ -21,7 +38,27 @@ function toBoolean(value: number | null): boolean | undefined {
return value === 1;
}
export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
export function hydrateMessage(
db: ReadableDB,
row: MessageTypeUnhydrated
): MessageType {
return hydrateMessages(db, [row])[0];
}
export function hydrateMessages(
db: ReadableDB,
unhydratedMessages: Array<MessageTypeUnhydrated>
): Array<MessageType> {
const messagesWithColumnsHydrated = unhydratedMessages.map(
hydrateMessageTableColumns
);
return hydrateMessagesWithAttachments(db, messagesWithColumnsHydrated);
}
export function hydrateMessageTableColumns(
row: MessageTypeUnhydrated
): MessageType {
const {
json,
id,
@ -29,9 +66,6 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
conversationId,
expirationStartTimestamp,
expireTimer,
hasAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
isErased,
isViewOnce,
mentionsMe,
@ -63,9 +97,6 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
conversationId: conversationId || '',
expirationStartTimestamp: dropNull(expirationStartTimestamp),
expireTimer: dropNull(expireTimer) as MessageType['expireTimer'],
hasAttachments: toBoolean(hasAttachments),
hasFileAttachments: toBoolean(hasFileAttachments),
hasVisualMediaAttachments: toBoolean(hasVisualMediaAttachments),
isErased: toBoolean(isErased),
isViewOnce: toBoolean(isViewOnce),
mentionsMe: toBoolean(mentionsMe),
@ -86,3 +117,299 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
unidentifiedDeliveryReceived: toBoolean(unidentifiedDeliveryReceived),
};
}
export function getAttachmentReferencesForMessages(
db: ReadableDB,
messageIds: Array<string>
): Array<MessageAttachmentDBType> {
return batchMultiVarQuery(
db,
messageIds,
(
messageIdBatch: ReadonlyArray<string>,
persistent: boolean
): Array<MessageAttachmentDBType> => {
const [query, params] = sql`
SELECT * FROM message_attachments
WHERE messageId IN (${sqlJoin(messageIdBatch)});
`;
return db
.prepare(query, { persistent })
.all<MessageAttachmentDBType>(params);
}
);
}
function hydrateMessagesWithAttachments(
db: ReadableDB,
messagesWithoutAttachments: Array<MessageType>
): Array<MessageType> {
const attachmentReferencesForAllMessages = getAttachmentReferencesForMessages(
db,
messagesWithoutAttachments.map(msg => msg.id)
);
const attachmentReferencesByMessage = groupBy(
attachmentReferencesForAllMessages,
'messageId'
);
return messagesWithoutAttachments.map(msg => {
const attachmentReferences = attachmentReferencesByMessage[msg.id] ?? [];
if (!attachmentReferences.length) {
return msg;
}
const attachmentsByEditHistoryIndex = groupBy(
attachmentReferences,
'editHistoryIndex'
);
const message = hydrateMessageRootOrRevisionWithAttachments(
msg,
attachmentsByEditHistoryIndex[
ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX
] ?? []
);
if (message.editHistory) {
message.editHistory = message.editHistory.map((editHistory, idx) => {
return hydrateMessageRootOrRevisionWithAttachments(
editHistory,
attachmentsByEditHistoryIndex[idx] ?? []
);
});
}
return message;
});
}
function hydrateMessageRootOrRevisionWithAttachments<
T extends Pick<
MessageAttributesType,
| 'attachments'
| 'bodyAttachment'
| 'contact'
| 'preview'
| 'quote'
| 'sticker'
>,
>(message: T, messageAttachments: Array<MessageAttachmentDBType>): T {
const attachmentsByType = groupBy(
messageAttachments,
'attachmentType'
) as Record<
MessageAttachmentDBType['attachmentType'],
Array<MessageAttachmentDBType>
>;
const standardAttachments = attachmentsByType.attachment ?? [];
const bodyAttachments = attachmentsByType['long-message'] ?? [];
const quoteAttachments = attachmentsByType.quote ?? [];
const previewAttachments = attachmentsByType.preview ?? [];
const contactAttachments = attachmentsByType.contact ?? [];
const stickerAttachment = (attachmentsByType.sticker ?? []).find(
sticker => sticker.orderInMessage === 0
);
const hydratedMessage = structuredClone(message);
if (standardAttachments.length) {
hydratedMessage.attachments = standardAttachments
.sort((a, b) => a.orderInMessage - b.orderInMessage)
.map(convertAttachmentDBFieldsToAttachmentType);
}
if (bodyAttachments[0]) {
hydratedMessage.bodyAttachment = convertAttachmentDBFieldsToAttachmentType(
bodyAttachments[0]
);
}
hydratedMessage.quote?.attachments.forEach((quoteAttachment, idx) => {
const quoteThumbnail = quoteAttachments.find(
attachment => attachment.orderInMessage === idx
);
if (quoteThumbnail) {
// eslint-disable-next-line no-param-reassign
quoteAttachment.thumbnail =
convertAttachmentDBFieldsToAttachmentType(quoteThumbnail);
}
});
hydratedMessage.preview?.forEach((preview, idx) => {
const previewAttachment = previewAttachments.find(
attachment => attachment.orderInMessage === idx
);
if (previewAttachment) {
// eslint-disable-next-line no-param-reassign
preview.image =
convertAttachmentDBFieldsToAttachmentType(previewAttachment);
}
});
hydratedMessage.contact?.forEach((contact, idx) => {
const contactAttachment = contactAttachments.find(
attachment => attachment.orderInMessage === idx
);
if (contactAttachment && contact.avatar) {
// eslint-disable-next-line no-param-reassign
contact.avatar.avatar =
convertAttachmentDBFieldsToAttachmentType(contactAttachment);
}
});
if (hydratedMessage.sticker && stickerAttachment) {
hydratedMessage.sticker.data =
convertAttachmentDBFieldsToAttachmentType(stickerAttachment);
}
return hydratedMessage;
}
function convertAttachmentDBFieldsToAttachmentType(
dbFields: MessageAttachmentDBType
): AttachmentType {
const messageAttachment = shallowDropNull(dbFields);
strictAssert(messageAttachment != null, 'must exist');
const {
clientUuid,
size,
contentType,
plaintextHash,
path,
localKey,
caption,
blurHash,
height,
width,
digest,
iv,
key,
downloadPath,
flags,
fileName,
version,
incrementalMac,
incrementalMacChunkSize: chunkSize,
transitCdnKey: cdnKey,
transitCdnNumber: cdnNumber,
transitCdnUploadTimestamp: uploadTimestamp,
error,
pending,
wasTooBig,
isCorrupted,
backfillError,
storyTextAttachmentJson,
copiedFromQuotedAttachment,
isReencryptableToSameDigest,
localBackupPath,
} = messageAttachment;
const result: AttachmentType = {
clientUuid,
size,
contentType: stringToMIMEType(contentType),
plaintextHash,
path,
localKey,
caption,
blurHash,
height,
width,
digest,
iv,
key,
downloadPath,
localBackupPath,
flags,
fileName,
version,
incrementalMac,
chunkSize,
cdnKey,
cdnNumber,
uploadTimestamp,
pending: convertOptionalIntegerToBoolean(pending),
error: convertOptionalIntegerToBoolean(error),
wasTooBig: convertOptionalIntegerToBoolean(wasTooBig),
copied: convertOptionalIntegerToBoolean(copiedFromQuotedAttachment),
isCorrupted: convertOptionalIntegerToBoolean(isCorrupted),
backfillError: convertOptionalIntegerToBoolean(backfillError),
isReencryptableToSameDigest: convertOptionalIntegerToBoolean(
isReencryptableToSameDigest
),
textAttachment: storyTextAttachmentJson
? jsonToObject(storyTextAttachmentJson)
: undefined,
...(messageAttachment.backupMediaName
? {
backupLocator: {
mediaName: messageAttachment.backupMediaName,
cdnNumber: messageAttachment.backupCdnNumber,
},
}
: {}),
...(messageAttachment.thumbnailPath
? {
thumbnail: {
path: messageAttachment.thumbnailPath,
size: messageAttachment.thumbnailSize ?? 0,
contentType: messageAttachment.thumbnailContentType
? stringToMIMEType(messageAttachment.thumbnailContentType)
: IMAGE_JPEG,
localKey: messageAttachment.thumbnailLocalKey,
version: messageAttachment.thumbnailVersion,
},
}
: {}),
...(messageAttachment.screenshotPath
? {
screenshot: {
path: messageAttachment.screenshotPath,
size: messageAttachment.screenshotSize ?? 0,
contentType: messageAttachment.screenshotContentType
? stringToMIMEType(messageAttachment.screenshotContentType)
: IMAGE_JPEG,
localKey: messageAttachment.screenshotLocalKey,
version: messageAttachment.screenshotVersion,
},
}
: {}),
...(messageAttachment.backupThumbnailPath
? {
thumbnailFromBackup: {
path: messageAttachment.backupThumbnailPath,
size: messageAttachment.backupThumbnailSize ?? 0,
contentType: messageAttachment.backupThumbnailContentType
? stringToMIMEType(messageAttachment.backupThumbnailContentType)
: IMAGE_JPEG,
localKey: messageAttachment.backupThumbnailLocalKey,
version: messageAttachment.backupThumbnailVersion,
},
}
: {}),
};
if (result.isReencryptableToSameDigest === false) {
if (
!messageAttachment.reencryptionIv ||
!messageAttachment.reencryptionKey ||
!messageAttachment.reencryptionDigest
) {
sqlLogger.warn(
'Attachment missing reencryption info despite not being reencryptable'
);
return result;
}
result.reencryptionInfo = {
iv: messageAttachment.reencryptionIv,
key: messageAttachment.reencryptionKey,
digest: messageAttachment.reencryptionDigest,
};
}
return result;
}

View file

@ -3,15 +3,11 @@
import { parentPort } from 'worker_threads';
import type { LoggerType } from '../types/Logging';
import type {
WrappedWorkerRequest,
WrappedWorkerResponse,
WrappedWorkerLogEntry,
} from './main';
import type { WrappedWorkerRequest, WrappedWorkerResponse } from './main';
import type { WritableDB } from './Interface';
import { initialize, DataReader, DataWriter, removeDB } from './Server';
import { SqliteErrorKind, parseSqliteError } from './errors';
import { sqlLogger as logger } from './sqlLogger';
if (!parentPort) {
throw new Error('Must run as a worker thread');
@ -31,39 +27,6 @@ function respond(seq: number, response?: any) {
port.postMessage(wrappedResponse);
}
const log = (
level: WrappedWorkerLogEntry['level'],
args: Array<unknown>
): void => {
const wrappedResponse: WrappedWorkerResponse = {
type: 'log',
level,
args,
};
port.postMessage(wrappedResponse);
};
const logger: LoggerType = {
fatal(...args: Array<unknown>) {
log('fatal', args);
},
error(...args: Array<unknown>) {
log('error', args);
},
warn(...args: Array<unknown>) {
log('warn', args);
},
info(...args: Array<unknown>) {
log('info', args);
},
debug(...args: Array<unknown>) {
log('debug', args);
},
trace(...args: Array<unknown>) {
log('trace', args);
},
};
let db: WritableDB | undefined;
let isPrimary = false;
let isRemoved = false;
@ -79,7 +42,6 @@ const onMessage = (
db = initialize({
...request.options,
isPrimary,
logger,
});
respond(seq, undefined);

View file

@ -0,0 +1,111 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { LoggerType } from '../../types/Logging';
import type { WritableDB } from '../Interface';
export const version = 1360;
export function updateToSchemaVersion1360(
currentVersion: number,
db: WritableDB,
logger: LoggerType
): void {
if (currentVersion >= 1360) {
return;
}
db.transaction(() => {
db.exec(`
DROP TABLE IF EXISTS message_attachments;
`);
db.exec(`
CREATE TABLE message_attachments (
messageId TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
-- For editHistoryIndex to be part of the primary key, it cannot be NULL in strict tables.
-- For that reason, we use a value of -1 to indicate that it is the root message (not in editHistory)
editHistoryIndex INTEGER NOT NULL,
attachmentType TEXT NOT NULL, -- 'long-message' | 'quote' | 'attachment' | 'preview' | 'contact' | 'sticker'
orderInMessage INTEGER NOT NULL,
conversationId TEXT NOT NULL,
sentAt INTEGER NOT NULL,
clientUuid TEXT,
size INTEGER NOT NULL,
contentType TEXT NOT NULL,
path TEXT,
plaintextHash TEXT,
localKey TEXT,
caption TEXT,
fileName TEXT,
blurHash TEXT,
height INTEGER,
width INTEGER,
digest TEXT,
key TEXT,
iv TEXT,
downloadPath TEXT,
version INTEGER,
incrementalMac TEXT,
incrementalMacChunkSize INTEGER,
transitCdnKey TEXT,
transitCdnNumber INTEGER,
transitCdnUploadTimestamp INTEGER,
backupMediaName TEXT,
backupCdnNumber INTEGER,
isReencryptableToSameDigest INTEGER,
reencryptionIv TEXT,
reencryptionKey TEXT,
reencryptionDigest TEXT,
thumbnailPath TEXT,
thumbnailSize INTEGER,
thumbnailContentType TEXT,
thumbnailLocalKey TEXT,
thumbnailVersion INTEGER,
screenshotPath TEXT,
screenshotSize INTEGER,
screenshotContentType TEXT,
screenshotLocalKey TEXT,
screenshotVersion INTEGER,
backupThumbnailPath TEXT,
backupThumbnailSize INTEGER,
backupThumbnailContentType TEXT,
backupThumbnailLocalKey TEXT,
backupThumbnailVersion INTEGER,
storyTextAttachmentJson TEXT,
localBackupPath TEXT,
flags INTEGER,
error INTEGER,
wasTooBig INTEGER,
isCorrupted INTEGER,
copiedFromQuotedAttachment INTEGER,
pending INTEGER,
backfillError INTEGER,
PRIMARY KEY (messageId, editHistoryIndex, attachmentType, orderInMessage)
) STRICT;
`);
db.exec(
'CREATE INDEX message_attachments_messageId ON message_attachments (messageId);'
);
db.exec(
'CREATE INDEX message_attachments_plaintextHash ON message_attachments (plaintextHash);'
);
db.exec(
'CREATE INDEX message_attachments_path ON message_attachments (path);'
);
db.exec(
'CREATE INDEX message_attachments_all_thumbnailPath ON message_attachments (thumbnailPath);'
);
db.exec(
'CREATE INDEX message_attachments_all_screenshotPath ON message_attachments (screenshotPath);'
);
db.exec(
'CREATE INDEX message_attachments_all_backupThumbnailPath ON message_attachments (backupThumbnailPath);'
);
db.pragma('user_version = 1360');
})();
logger.info('updateToSchemaVersion1360: success!');
}

View file

@ -110,10 +110,11 @@ import { updateToSchemaVersion1310 } from './1310-muted-fixup';
import { updateToSchemaVersion1320 } from './1320-unprocessed-received-at-date';
import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index';
import { updateToSchemaVersion1340 } from './1340-recent-gifs';
import { updateToSchemaVersion1350 } from './1350-notification-profiles';
import {
updateToSchemaVersion1350,
updateToSchemaVersion1360,
version as MAX_VERSION,
} from './1350-notification-profiles';
} from './1360-attachments';
import { DataWriter } from '../Server';
@ -2102,6 +2103,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1330,
updateToSchemaVersion1340,
updateToSchemaVersion1350,
updateToSchemaVersion1360,
];
export class DBVersionFromFutureError extends Error {

45
ts/sql/sqlLogger.ts Normal file
View file

@ -0,0 +1,45 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { parentPort } from 'worker_threads';
import type { LoggerType } from '../types/Logging';
import type { WrappedWorkerLogEntry, WrappedWorkerResponse } from './main';
import { consoleLogger } from '../util/consoleLogger';
import { strictAssert } from '../util/assert';
const log = (
level: WrappedWorkerLogEntry['level'],
args: Array<unknown>
): void => {
if (parentPort) {
const wrappedResponse: WrappedWorkerResponse = {
type: 'log',
level,
args,
};
parentPort.postMessage(wrappedResponse);
} else {
strictAssert(process.env.NODE_ENV === 'test', 'must be test environment');
consoleLogger[level](...args);
}
};
export const sqlLogger: LoggerType = {
fatal(...args: Array<unknown>) {
log('fatal', args);
},
error(...args: Array<unknown>) {
log('error', args);
},
warn(...args: Array<unknown>) {
log('warn', args);
},
info(...args: Array<unknown>) {
log('info', args);
},
debug(...args: Array<unknown>) {
log('debug', args);
},
trace(...args: Array<unknown>) {
log('trace', args);
},
};

View file

@ -418,3 +418,27 @@ export class TableIterator<ObjectType extends { id: string }> {
}
}
}
export function convertOptionalIntegerToBoolean(
optionalInteger?: number
): boolean | undefined {
if (optionalInteger === 1) {
return true;
}
if (optionalInteger === 0) {
return false;
}
return undefined;
}
export function convertOptionalBooleanToNullableInteger(
optionalBoolean?: boolean
): 1 | 0 | null {
if (optionalBoolean === true) {
return 1;
}
if (optionalBoolean === false) {
return 0;
}
return null;
}

View file

@ -328,10 +328,9 @@ function showLightbox(opts: {
sentAt,
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl || item.thumbnail?.path
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
thumbnailObjectUrl: item.thumbnail?.path
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
size: item.size,
totalDownloaded: item.totalDownloaded,
}))

View file

@ -1887,18 +1887,16 @@ export function getPropsForAttachment(
function processQuoteAttachment(attachment: QuotedAttachmentType) {
const { thumbnail } = attachment;
const path = thumbnail && thumbnail.path && getLocalAttachmentUrl(thumbnail);
const objectUrl = thumbnail && thumbnail.objectUrl;
const thumbnailWithObjectUrl =
(!path && !objectUrl) || !thumbnail
? undefined
: { ...thumbnail, objectUrl: path || objectUrl };
return {
...attachment,
isVoiceMessage: isVoiceMessage(attachment),
thumbnail: thumbnailWithObjectUrl,
thumbnail: thumbnail?.path
? {
...thumbnail,
url: getLocalAttachmentUrl(thumbnail),
}
: undefined,
};
}

View file

@ -55,6 +55,7 @@ describe('backup/attachments', () => {
beforeEach(async () => {
await DataWriter.removeAll();
window.storage.reset();
window.ConversationController.reset();
await setupBasics();
@ -166,8 +167,6 @@ describe('backup/attachments', () => {
// path & iv will not be roundtripped
[
composeMessage(1, {
hasAttachments: true,
hasVisualMediaAttachments: true,
attachments: [
omit(longMessageAttachment, NON_ROUNDTRIPPED_FIELDS),
omit(normalAttachment, NON_ROUNDTRIPPED_FIELDS),
@ -284,8 +283,6 @@ describe('backup/attachments', () => {
// path & iv will not be roundtripped
[
composeMessage(1, {
hasAttachments: true,
hasVisualMediaAttachments: true,
attachments: [
omit(attachment1, NON_ROUNDTRIPPED_FIELDS),
omit(attachment2, NON_ROUNDTRIPPED_FIELDS),
@ -307,9 +304,6 @@ describe('backup/attachments', () => {
],
[
composeMessage(1, {
hasAttachments: true,
hasVisualMediaAttachments: true,
// path, iv, and uploadTimestamp will not be roundtripped,
// but there will be a backupLocator
attachments: [
@ -341,7 +335,6 @@ describe('backup/attachments', () => {
],
[
composeMessage(1, {
hasAttachments: true,
attachments: [
{
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
@ -373,7 +366,6 @@ describe('backup/attachments', () => {
[
composeMessage(1, {
body: 'hello',
hasAttachments: true,
attachments: [
{
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
@ -637,8 +629,6 @@ describe('backup/attachments', () => {
[
{
...existingMessage,
hasAttachments: true,
hasVisualMediaAttachments: true,
attachments: [
{
...omit(

View file

@ -110,9 +110,6 @@ function sortAndNormalize(
return JSON.parse(
JSON.stringify({
// Defaults
hasAttachments: false,
hasFileAttachments: false,
hasVisualMediaAttachments: false,
isErased: false,
isViewOnce: false,
mentionsMe: false,

View file

@ -0,0 +1,259 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { emptyDir, ensureFile } from 'fs-extra';
import { v4 as generateUuid } from 'uuid';
import { readdirSync } from 'fs';
import { dirname } from 'path';
import { DataWriter } from '../sql/Client';
import { missingCaseError } from '../util/missingCaseError';
import {
getDownloadsPath,
getDraftPath,
getPath,
} from '../windows/attachments';
import { generateAci } from '../types/ServiceId';
import { IMAGE_JPEG, LONG_MESSAGE } from '../types/MIME';
function getAbsolutePath(
path: string,
type: 'attachment' | 'download' | 'draft'
) {
switch (type) {
case 'attachment':
return window.Signal.Migrations.getAbsoluteAttachmentPath(path);
case 'download':
return window.Signal.Migrations.getAbsoluteDownloadsPath(path);
case 'draft':
return window.Signal.Migrations.getAbsoluteDraftPath(path);
default:
throw missingCaseError(type);
}
}
async function writeFile(
path: string,
type: 'attachment' | 'download' | 'draft'
) {
await ensureFile(getAbsolutePath(path, type));
}
async function writeFiles(
num: number,
type: 'attachment' | 'download' | 'draft'
) {
for (let i = 0; i < num; i += 1) {
// eslint-disable-next-line no-await-in-loop
await writeFile(`file${i}`, type);
}
}
function listFiles(type: 'attachment' | 'download' | 'draft'): Array<string> {
return readdirSync(dirname(getAbsolutePath('fakename', type)));
}
describe('cleanupOrphanedAttachments', () => {
// TODO (DESKTOP-8613): stickers & badges
beforeEach(async () => {
await DataWriter.removeAll();
await emptyDir(getPath(window.SignalContext.config.userDataPath));
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
});
afterEach(async () => {
await emptyDir(getPath(window.SignalContext.config.userDataPath));
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
});
it('deletes paths if not referenced', async () => {
await writeFiles(2, 'attachment');
await writeFiles(2, 'draft');
await writeFiles(2, 'download');
assert.sameDeepMembers(listFiles('attachment'), ['file0', 'file1']);
assert.sameDeepMembers(listFiles('draft'), ['file0', 'file1']);
assert.sameDeepMembers(listFiles('download'), ['file0', 'file1']);
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.sameDeepMembers(listFiles('attachment'), []);
assert.sameDeepMembers(listFiles('draft'), []);
assert.sameDeepMembers(listFiles('download'), []);
});
it('does not delete conversation avatar and profileAvatar paths', async () => {
await writeFiles(6, 'attachment');
await DataWriter.saveConversation({
id: generateUuid(),
type: 'private',
version: 2,
expireTimerVersion: 2,
avatar: {
path: 'file0',
},
profileAvatar: {
path: 'file1',
},
});
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.sameDeepMembers(listFiles('attachment'), ['file0', 'file1']);
});
it('does not delete message attachments (including thumbnails, previews, avatars, etc.)', async () => {
await writeFiles(20, 'attachment');
await writeFiles(6, 'download');
// Save with legacy (un-normalized) sattachment format (attachments in JSON)
await DataWriter.saveMessage(
{
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
attachments: [
{
contentType: IMAGE_JPEG,
size: 128,
path: 'file0',
downloadPath: 'file0',
thumbnail: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file1',
},
screenshot: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file2',
},
thumbnailFromBackup: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file3',
},
},
],
},
{
ourAci: generateAci(),
forceSave: true,
_testOnlyAvoidNormalizingAttachments: true,
postSaveUpdates: () => Promise.resolve(),
}
);
// Save one with attachments normalized
await DataWriter.saveMessage(
{
id: generateUuid(),
type: 'outgoing',
sent_at: Date.now(),
timestamp: Date.now(),
received_at: Date.now(),
conversationId: generateUuid(),
bodyAttachment: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file4',
},
contact: [
{
avatar: {
isProfile: false,
avatar: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file5',
},
},
},
],
preview: [
{
url: 'url',
image: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file6',
},
},
],
editHistory: [
{
timestamp: Date.now(),
received_at: Date.now(),
bodyAttachment: {
contentType: LONG_MESSAGE,
size: 128,
path: 'file7',
},
},
],
quote: {
id: Date.now(),
isViewOnce: false,
referencedMessageNotFound: false,
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file8',
},
},
],
},
sticker: {
packId: 'packId',
stickerId: 42,
packKey: 'packKey',
data: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file9',
thumbnail: {
contentType: IMAGE_JPEG,
size: 128,
path: 'file10',
},
},
},
},
{
ourAci: generateAci(),
forceSave: true,
postSaveUpdates: () => Promise.resolve(),
}
);
await DataWriter.cleanupOrphanedAttachments({ _block: true });
assert.sameDeepMembers(listFiles('attachment'), [
'file0',
'file1',
'file2',
'file3',
'file4',
'file5',
'file6',
'file7',
'file8',
'file9',
'file10',
]);
assert.sameDeepMembers(listFiles('download'), ['file0']);
});
});

View file

@ -64,9 +64,6 @@ describe('Conversations', () => {
body: 'bananas',
conversationId: conversation.id,
expirationStartTimestamp: now,
hasAttachments: false,
hasFileAttachments: false,
hasVisualMediaAttachments: false,
id: generateUuid(),
received_at: now,
sent_at: now,

View file

@ -0,0 +1,608 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as generateGuid } from 'uuid';
import * as Bytes from '../Bytes';
import type {
EphemeralAttachmentFields,
ScreenshotType,
AttachmentType,
ThumbnailType,
BackupThumbnailType,
} from '../types/Attachment';
import { IMAGE_JPEG, IMAGE_PNG, LONG_MESSAGE } from '../types/MIME';
import type { MessageAttributesType } from '../model-types';
import { generateAci } from '../types/ServiceId';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
import { DataWriter, DataReader } from '../sql/Client';
import { strictAssert } from '../util/assert';
import { HOUR, MINUTE } from '../util/durations';
const CONTACT_A = generateAci();
const contactAConversationId = generateGuid();
function getBase64(str: string): string {
return Bytes.toBase64(Bytes.fromString(str));
}
function composeThumbnail(
index: number,
overrides?: Partial<AttachmentType>
): ThumbnailType {
return {
size: 1024,
contentType: IMAGE_PNG,
path: `path/to/thumbnail${index}`,
localKey: `thumbnailLocalKey${index}`,
version: 2,
...overrides,
};
}
function composeBackupThumbnail(
index: number,
overrides?: Partial<AttachmentType>
): BackupThumbnailType {
return {
size: 1024,
contentType: IMAGE_JPEG,
path: `path/to/backupThumbnail${index}`,
localKey: 'backupThumbnailLocalKey',
version: 2,
...overrides,
};
}
function composeScreenshot(
index: number,
overrides?: Partial<AttachmentType>
): ScreenshotType {
return {
size: 1024,
contentType: IMAGE_PNG,
path: `path/to/screenshot${index}`,
localKey: `screenshotLocalKey${index}`,
version: 2,
...overrides,
};
}
let index = 0;
function composeAttachment(
key?: string,
overrides?: Partial<AttachmentType>
// NB: Required<AttachmentType> to ensure we are roundtripping every property in
// AttachmentType! If you are here you probably just added a field to AttachmentType;
// Make sure you add a column to the `message_attachments` table and update
// MESSAGE_ATTACHMENT_COLUMNS.
): Required<Omit<AttachmentType, keyof EphemeralAttachmentFields>> {
const label = `${key ?? 'attachment'}${index}`;
const attachment = {
cdnKey: `cdnKey${label}`,
cdnNumber: 3,
key: getBase64(`key${label}`),
digest: getBase64(`digest${label}`),
iv: getBase64(`iv${label}`),
size: 100,
downloadPath: 'downloadPath',
contentType: IMAGE_JPEG,
path: `path/to/file${label}`,
pending: false,
localKey: 'localKey',
plaintextHash: `plaintextHash${label}`,
uploadTimestamp: index,
clientUuid: generateGuid(),
width: 100,
height: 120,
blurHash: 'blurHash',
caption: 'caption',
fileName: 'filename',
flags: 8,
incrementalMac: 'incrementalMac',
chunkSize: 128,
isReencryptableToSameDigest: true,
version: 2,
backupLocator: {
mediaName: `medianame${label}`,
cdnNumber: index,
},
localBackupPath: `localBackupPath/${label}`,
// This would only exist on a story message with contentType TEXT_ATTACHMENT,
// but inluding it here to ensure we are roundtripping all fields
textAttachment: {
text: 'text',
textStyle: 3,
},
// defaulting all of these booleans to true to ensure that we are actually
// roundtripping them to/from the DB
wasTooBig: true,
error: true,
isCorrupted: true,
backfillError: true,
copied: true,
thumbnail: composeThumbnail(index),
screenshot: composeScreenshot(index),
thumbnailFromBackup: composeBackupThumbnail(index),
...overrides,
} as const;
index += 1;
return attachment;
}
function composeMessage(
timestamp: number,
overrides?: Partial<MessageAttributesType>
): MessageAttributesType {
return {
schemaVersion: 12,
conversationId: contactAConversationId,
id: generateGuid(),
type: 'incoming',
body: undefined,
received_at: timestamp,
received_at_ms: timestamp,
sourceServiceId: CONTACT_A,
sourceDevice: 1,
sent_at: timestamp,
timestamp,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
isErased: false,
mentionsMe: false,
isViewOnce: false,
unidentifiedDeliveryReceived: false,
serverGuid: undefined,
serverTimestamp: undefined,
source: undefined,
storyId: undefined,
expirationStartTimestamp: undefined,
expireTimer: undefined,
...overrides,
};
}
describe('normalizes attachment references', () => {
beforeEach(async () => {
await DataWriter.removeAll();
});
it('saves message with undownloaded attachments', async () => {
const attachment1: AttachmentType = {
...composeAttachment(),
path: undefined,
localKey: undefined,
plaintextHash: undefined,
version: undefined,
};
const attachment2: AttachmentType = {
...composeAttachment(),
path: undefined,
localKey: undefined,
plaintextHash: undefined,
version: undefined,
};
delete attachment1.thumbnail;
delete attachment1.screenshot;
delete attachment1.thumbnailFromBackup;
delete attachment2.thumbnail;
delete attachment2.screenshot;
delete attachment2.thumbnailFromBackup;
const attachments = [attachment1, attachment2];
const message = composeMessage(Date.now(), {
attachments,
});
await DataWriter.saveMessage(message, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const references = await DataReader.getAttachmentReferencesForMessages([
message.id,
]);
assert.equal(references.length, attachments.length);
const messageFromDB = await DataReader.getMessageById(message.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, message);
});
it('saves message with downloaded attachments, and hydrates on get', async () => {
const attachments = [
composeAttachment('first'),
composeAttachment('second'),
];
const message = composeMessage(Date.now(), {
attachments,
});
await DataWriter.saveMessage(message, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageFromDB = await DataReader.getMessageById(message.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, message);
});
it('saves and re-hydrates messages with normal, body, preview, quote, contact, and sticker attachments', async () => {
const attachment1 = composeAttachment('first');
const attachment2 = composeAttachment('second');
const previewAttachment1 = composeAttachment('preview1');
const previewAttachment2 = composeAttachment('preview2');
const quoteAttachment1 = composeAttachment('quote1');
const quoteAttachment2 = composeAttachment('quote2');
const contactAttachment1 = composeAttachment('contact1');
const contactAttachment2 = composeAttachment('contact2');
const stickerAttachment = composeAttachment('sticker');
const bodyAttachment = composeAttachment('body', {
contentType: LONG_MESSAGE,
});
const message = composeMessage(Date.now(), {
attachments: [attachment1, attachment2],
bodyAttachment,
preview: [
{
title: 'preview',
description: 'description',
domain: 'domain',
url: 'https://signal.org',
isStickerPack: false,
isCallLink: false,
image: previewAttachment1,
date: Date.now(),
},
{
title: 'preview2',
description: 'description2',
domain: 'domain2',
url: 'https://signal2.org',
isStickerPack: true,
isCallLink: false,
image: previewAttachment2,
date: Date.now(),
},
],
quote: {
id: Date.now(),
referencedMessageNotFound: true,
isViewOnce: false,
messageId: 'quotedMessageId',
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: quoteAttachment1,
},
{
contentType: IMAGE_PNG,
thumbnail: quoteAttachment2,
},
],
},
contact: [
{
name: {
givenName: 'Alice',
familyName: 'User',
},
avatar: {
isProfile: true,
avatar: contactAttachment1,
},
},
{
name: {
givenName: 'Bob',
familyName: 'User',
},
avatar: {
isProfile: false,
avatar: contactAttachment2,
},
},
],
sticker: {
packId: 'stickerPackId',
stickerId: 123,
packKey: 'abcdefg',
data: stickerAttachment,
},
});
await DataWriter.saveMessage(message, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageFromDB = await DataReader.getMessageById(message.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, message);
});
it('handles quote attachments with copied thumbnail', async () => {
const referencedAttachment = composeAttachment('quotedattachment', {
thumbnail: composeThumbnail(0),
});
strictAssert(referencedAttachment.plaintextHash, 'exists');
const referencedMessage = composeMessage(1, {
attachments: [referencedAttachment],
});
const quoteMessage = composeMessage(2, {
quote: {
id: Date.now(),
referencedMessageNotFound: false,
isViewOnce: false,
messageId: 'quotedMessageId',
attachments: [
{
fileName: 'filename',
contentType: IMAGE_PNG,
thumbnail: { ...composeAttachment(), copied: true },
},
],
},
});
await DataWriter.saveMessage(referencedMessage, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
await DataWriter.saveMessage(quoteMessage, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageFromDB = await DataReader.getMessageById(quoteMessage.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, quoteMessage);
});
it('deletes and re-orders attachments as necessary', async () => {
await DataWriter.removeAll();
const attachment1 = composeAttachment();
const attachment2 = composeAttachment();
const attachment3 = composeAttachment();
const attachments = [attachment1, attachment2, attachment3];
const message = composeMessage(Date.now(), {
attachments,
});
await DataWriter.saveMessage(message, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageFromDB = await DataReader.getMessageById(message.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, message);
/** Re-order the attachments */
const messageWithReorderedAttachments = {
...message,
attachments: [attachment3, attachment2, attachment1],
};
await DataWriter.saveMessage(messageWithReorderedAttachments, {
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageWithReorderedAttachmentsFromDB =
await DataReader.getMessageById(message.id);
assert(messageWithReorderedAttachmentsFromDB, 'message was saved');
assert.deepEqual(
messageWithReorderedAttachmentsFromDB,
messageWithReorderedAttachments
);
/** Drop the last attachment */
const messageWithDeletedAttachment = {
...message,
attachments: [attachment1, attachment2],
};
await DataWriter.saveMessage(messageWithDeletedAttachment, {
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageWithDeletedAttachmentFromDB = await DataReader.getMessageById(
message.id
);
assert(messageWithDeletedAttachmentFromDB, 'message was saved');
assert.deepEqual(
messageWithDeletedAttachmentFromDB,
messageWithDeletedAttachment
);
});
it('deletes attachment references when message is deleted', async () => {
const attachment1 = composeAttachment();
const attachment2 = composeAttachment();
const attachments = [attachment1, attachment2];
const message = composeMessage(Date.now(), {
attachments,
});
const message2 = composeMessage(Date.now(), {
attachments: [composeAttachment()],
});
await DataWriter.saveMessages([message, message2], {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
assert.equal(
(await DataReader.getAttachmentReferencesForMessages([message.id]))
.length,
2
);
assert.equal(
(await DataReader.getAttachmentReferencesForMessages([message2.id]))
.length,
1
);
// Deleting message should delete all references
await DataWriter._removeMessage(message.id);
assert.deepEqual(
await DataReader.getAttachmentReferencesForMessages([message.id]),
[]
);
assert.equal(
(await DataReader.getAttachmentReferencesForMessages([message2.id]))
.length,
1
);
});
it('roundtrips edithistory attachments with normal, body, preview, and quote attachments', async () => {
const mainMessageFields = {
attachments: [composeAttachment('main1'), composeAttachment('main2')],
bodyAttachment: composeAttachment('body1', {
contentType: LONG_MESSAGE,
}),
preview: [
{
title: 'preview',
description: 'description',
domain: 'domain',
url: 'https://signal.org',
isStickerPack: false,
isCallLink: false,
image: composeAttachment('preview1'),
date: Date.now(),
},
],
quote: {
id: Date.now(),
referencedMessageNotFound: true,
isViewOnce: false,
messageId: 'quotedMessageId',
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: composeAttachment('quote3'),
},
],
},
};
const now = Date.now();
const message = composeMessage(now, {
...mainMessageFields,
editMessageReceivedAt: now + HOUR + 42,
editMessageTimestamp: now + HOUR,
editHistory: [
{
timestamp: now + HOUR,
received_at: now + HOUR + 42,
attachments: [
composeAttachment('main.edit1.1'),
composeAttachment('main.edit1.2'),
],
bodyAttachment: composeAttachment('body.edit1', {
contentType: LONG_MESSAGE,
}),
preview: [
{
title: 'preview',
description: 'description',
domain: 'domain',
url: 'https://signal.org',
isStickerPack: false,
isCallLink: true,
image: composeAttachment('preview.edit1'),
date: Date.now(),
},
],
quote: {
id: Date.now(),
referencedMessageNotFound: true,
isViewOnce: false,
messageId: 'quotedMessageId',
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: composeAttachment('quote.edit1'),
},
],
},
},
{
timestamp: now + MINUTE,
received_at: now + MINUTE + 42,
attachments: [
composeAttachment('main.edit2.1'),
composeAttachment('main.edit2.2'),
],
bodyAttachment: composeAttachment('body.edit2', {
contentType: LONG_MESSAGE,
}),
preview: [
{
title: 'preview',
description: 'description',
domain: 'domain',
url: 'https://signal.org',
isStickerPack: false,
isCallLink: true,
image: composeAttachment('preview.edit2'),
date: Date.now(),
},
],
quote: {
id: Date.now(),
referencedMessageNotFound: true,
isViewOnce: false,
messageId: 'quotedMessageId',
attachments: [
{
contentType: IMAGE_JPEG,
thumbnail: composeAttachment('quote.edit2'),
},
],
},
},
{
timestamp: now,
received_at: now,
...mainMessageFields,
},
],
});
await DataWriter.saveMessage(message, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
});
const messageAttachments =
await DataReader.getAttachmentReferencesForMessages([message.id]);
// 5 attachments, plus 3 versions in editHistory = 20 attachments total
assert.deepEqual(messageAttachments.length, 20);
const messageFromDB = await DataReader.getMessageById(message.id);
assert(messageFromDB, 'message was saved');
assert.deepEqual(messageFromDB, message);
});
});

View file

@ -22,6 +22,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { MINUTE } from '../../util/durations';
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment';
import { AttachmentDownloadSource } from '../../sql/Interface';
import { getAttachmentCiphertextLength } from '../../AttachmentCrypto';
import { MEBIBYTE } from '../../types/AttachmentSize';
@ -507,14 +508,23 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
let processNewAttachment: sinon.SinonStub;
const abortController = new AbortController();
const downloadedAttachment: Awaited<
ReturnType<typeof downloadAttachmentUtil>
> = {
path: '/path/to/file',
iv: 'iv',
plaintextHash: 'plaintextHash',
isReencryptableToSameDigest: true,
localKey: 'localKey',
version: 2,
size: 128,
};
beforeEach(async () => {
sandbox = sinon.createSandbox();
downloadAttachment = sandbox.stub().returns({
path: '/path/to/file',
iv: Buffer.alloc(16),
plaintextHash: 'plaintextHash',
isReencryptableToSameDigest: true,
});
downloadAttachment = sandbox
.stub()
.returns(Promise.resolve(downloadedAttachment));
processNewAttachment = sandbox.stub().callsFake(attachment => attachment);
});
@ -611,6 +621,8 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
},
thumbnailFromBackup: {
path: '/path/to/thumbnail',
size: 128,
contentType: MIME.IMAGE_JPEG,
},
},
});
@ -724,11 +736,7 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
if (options.variant === AttachmentVariant.Default) {
throw new Error('error while downloading');
}
return {
path: '/path/to/thumbnail',
iv: Buffer.alloc(16),
plaintextHash: 'plaintextHash',
};
return downloadedAttachment;
});
const job = composeJob({

View file

@ -0,0 +1,89 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { sql, sqlJoin } from '../../sql/util';
import { createDB, explain, updateToVersion } from './helpers';
import type { WritableDB } from '../../sql/Interface';
import { DataWriter } from '../../sql/Server';
describe('SQL/updateToSchemaVersion1360', () => {
let db: WritableDB;
beforeEach(async () => {
db = createDB();
updateToVersion(db, 1360);
await DataWriter.removeAll(db);
});
afterEach(() => {
db.close();
});
describe('message attachments', () => {
it('uses covering index to delete based on messageId', async () => {
const details = explain(
db,
sql`DELETE from message_attachments WHERE messageId = ${'messageId'}`
);
assert.strictEqual(
details,
'SEARCH message_attachments USING COVERING INDEX message_attachments_messageId (messageId=?)'
);
});
it('uses index to select based on messageId', async () => {
const details = explain(
db,
sql`SELECT * from message_attachments WHERE messageId IN (${sqlJoin(['id1', 'id2'])});`
);
assert.strictEqual(
details,
'SEARCH message_attachments USING INDEX message_attachments_messageId (messageId=?)'
);
});
it('uses index find path with existing plaintextHash', async () => {
const details = explain(
db,
sql`
SELECT path, localKey
FROM message_attachments
WHERE plaintextHash = ${'plaintextHash'}
LIMIT 1;
`
);
assert.strictEqual(
details,
'SEARCH message_attachments USING INDEX message_attachments_plaintextHash (plaintextHash=?)'
);
});
it('uses all path indices to find if path is being referenced', async () => {
const path = 'path';
const details = explain(
db,
sql`
SELECT 1 FROM message_attachments
WHERE
path = ${path} OR
thumbnailPath = ${path} OR
screenshotPath = ${path} OR
backupThumbnailPath = ${path};
`
);
assert.deepStrictEqual(details.split('\n'), [
'MULTI-INDEX OR',
'INDEX 1',
'SEARCH message_attachments USING INDEX message_attachments_path (path=?)',
'INDEX 2',
'SEARCH message_attachments USING INDEX message_attachments_all_thumbnailPath (thumbnailPath=?)',
'INDEX 3',
'SEARCH message_attachments USING INDEX message_attachments_all_screenshotPath (screenshotPath=?)',
'INDEX 4',
'SEARCH message_attachments USING INDEX message_attachments_all_backupThumbnailPath (backupThumbnailPath=?)',
]);
});
});
});

View file

@ -197,9 +197,6 @@ describe('Message', () => {
fileName: 'test\uFFFDfig.exe',
},
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: undefined,
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
});
@ -848,6 +845,7 @@ describe('Message', () => {
const result = await Message.upgradeSchema(message, {
...getDefaultContext(),
doesAttachmentExist: async () => false,
maxVersion: 14,
});
assert.deepEqual({ ...message, schemaVersion: 14 }, result);

View file

@ -1,221 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as Message from '../../../types/message/initializeAttachmentMetadata';
import { SignalService } from '../../../protobuf';
import * as MIME from '../../../types/MIME';
import * as Bytes from '../../../Bytes';
import type { MessageAttributesType } from '../../../model-types.d';
function getDefaultMessage(
props?: Partial<MessageAttributesType>
): MessageAttributesType {
return {
id: 'some-id',
type: 'incoming',
sent_at: 45,
received_at: 45,
timestamp: 45,
conversationId: 'some-conversation-id',
...props,
};
}
describe('Message', () => {
describe('initializeAttachmentMetadata', () => {
it('should classify visual media attachments', async () => {
const input = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.IMAGE_JPEG,
data: Bytes.fromString('foo'),
fileName: 'foo.jpg',
size: 1111,
},
],
});
const expected = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.IMAGE_JPEG,
data: Bytes.fromString('foo'),
fileName: 'foo.jpg',
size: 1111,
},
],
hasAttachments: 1,
hasVisualMediaAttachments: 1,
hasFileAttachments: undefined,
});
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('should classify file attachments', async () => {
const input = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.APPLICATION_OCTET_STREAM,
data: Bytes.fromString('foo'),
fileName: 'foo.bin',
size: 1111,
},
],
});
const expected = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.APPLICATION_OCTET_STREAM,
data: Bytes.fromString('foo'),
fileName: 'foo.bin',
size: 1111,
},
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: 1,
});
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('should classify voice message attachments', async () => {
const input = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.AUDIO_AAC,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: Bytes.fromString('foo'),
fileName: 'Voice Message.aac',
size: 1111,
},
],
});
const expected = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.AUDIO_AAC,
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: Bytes.fromString('foo'),
fileName: 'Voice Message.aac',
size: 1111,
},
],
hasAttachments: 1,
hasVisualMediaAttachments: undefined,
hasFileAttachments: undefined,
});
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('does not include long message attachments', async () => {
const input = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.LONG_MESSAGE,
data: Bytes.fromString('foo'),
fileName: 'message.txt',
size: 1111,
},
],
});
const expected = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [
{
contentType: MIME.LONG_MESSAGE,
data: Bytes.fromString('foo'),
fileName: 'message.txt',
size: 1111,
},
],
hasAttachments: 0,
hasVisualMediaAttachments: undefined,
hasFileAttachments: undefined,
});
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
it('handles not attachments', async () => {
const input = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [],
});
const expected = getDefaultMessage({
type: 'incoming',
conversationId: 'foo',
id: '11111111-1111-1111-1111-111111111111',
timestamp: 1523317140899,
received_at: 1523317140899,
sent_at: 1523317140800,
attachments: [],
hasAttachments: 0,
hasVisualMediaAttachments: undefined,
hasFileAttachments: undefined,
});
const actual = await Message.initializeAttachmentMetadata(input);
assert.deepEqual(actual, expected);
});
});
});

View file

@ -114,7 +114,7 @@ export async function downloadAttachment(
variant: AttachmentVariant;
abortSignal: AbortSignal;
}
): Promise<ReencryptedAttachmentV2 & { size?: number }> {
): Promise<ReencryptedAttachmentV2> {
const logId = `downloadAttachment/${options.logPrefix ?? ''}`;
const { digest, incrementalMac, chunkSize, key, size } = attachment;
@ -272,20 +272,17 @@ export async function downloadAttachment(
// backup thumbnails don't get trimmed, so we just calculate the size as the
// ciphertextSize, less IV and MAC
const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH;
return {
...(await decryptAndReencryptLocally({
type: 'backupThumbnail',
ciphertextPath: cipherTextAbsolutePath,
idForLogging: logId,
size: calculatedSize,
...thumbnailEncryptionKeys,
outerEncryption:
getBackupThumbnailOuterEncryptionKeyMaterial(attachment),
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
})),
return decryptAndReencryptLocally({
type: 'backupThumbnail',
ciphertextPath: cipherTextAbsolutePath,
idForLogging: logId,
size: calculatedSize,
};
...thumbnailEncryptionKeys,
outerEncryption:
getBackupThumbnailOuterEncryptionKeyMaterial(attachment),
getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath,
});
}
default: {
throw missingCaseError(options.variant);

View file

@ -24,7 +24,11 @@ import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { LocalizerType, WithRequiredProperties } from './Util';
import type {
LocalizerType,
WithOptionalProperties,
WithRequiredProperties,
} from './Util';
import { ThemeType } from './Util';
import * as GoogleChrome from '../util/GoogleChrome';
import { ReadStatus } from '../messages/MessageReadStatus';
@ -56,14 +60,47 @@ export class AttachmentPermanentlyUndownloadableError extends Error {
}
}
type ScreenshotType = Omit<AttachmentType, 'size'> & {
height: number;
width: number;
path: string;
size?: number;
export type ThumbnailType = EphemeralAttachmentFields & {
size: number;
contentType: MIME.MIMEType;
path?: string;
plaintextHash?: string;
width?: number;
height?: number;
version?: 1 | 2;
localKey?: string; // AES + MAC
};
export type AttachmentType = {
export type ScreenshotType = WithOptionalProperties<ThumbnailType, 'size'>;
export type BackupThumbnailType = WithOptionalProperties<ThumbnailType, 'size'>;
// These fields do not get saved to the DB.
export type EphemeralAttachmentFields = {
totalDownloaded?: number;
data?: Uint8Array;
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url?: string;
screenshotData?: Uint8Array;
/** @deprecated Legacy field */
screenshotPath?: string;
/** @deprecated Legacy field. Used only for downloading old attachment */
id?: number;
/** @deprecated Legacy field, used long ago for migrating attachments to disk. */
schemaVersion?: number;
/** @deprecated Legacy field, replaced by cdnKey */
cdnId?: string;
};
/**
* Adding a field to AttachmentType requires:
* 1) adding a column to message_attachments
* 2) updating MessageAttachmentDBReferenceType and MESSAGE_ATTACHMENT_COLUMNS
* 3) saving data to the proper column
*/
export type AttachmentType = EphemeralAttachmentFields & {
error?: boolean;
blurHash?: string;
caption?: string;
@ -73,36 +110,27 @@ export type AttachmentType = {
fileName?: string;
plaintextHash?: string;
uploadTimestamp?: number;
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url?: string;
size: number;
pending?: boolean;
width?: number;
height?: number;
path?: string;
screenshot?: ScreenshotType;
screenshotData?: Uint8Array;
// Legacy Draft
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
downloadPath?: string;
key?: string;
iv?: string;
data?: Uint8Array;
textAttachment?: TextAttachmentType;
wasTooBig?: boolean;
// If `true` backfill is unavailable
backfillError?: boolean;
totalDownloaded?: number;
incrementalMac?: string;
chunkSize?: number;
@ -115,25 +143,19 @@ export type AttachmentType = {
// See app/attachment_channel.ts
version?: 1 | 2;
localKey?: string; // AES + MAC
thumbnailFromBackup?: Pick<
AttachmentType,
'path' | 'version' | 'plaintextHash'
>;
thumbnailFromBackup?: BackupThumbnailType;
/** Legacy field. Used only for downloading old attachments */
id?: number;
/** Legacy field, used long ago for migrating attachments to disk. */
schemaVersion?: number;
/** For quote attachments, if copied from the referenced attachment */
copied?: boolean;
} & (
| {
isReencryptableToSameDigest?: true;
}
| {
isReencryptableToSameDigest: false;
reencryptionInfo?: ReencryptionInfo;
}
);
| {
isReencryptableToSameDigest?: true;
}
| {
isReencryptableToSameDigest: false;
reencryptionInfo?: ReencryptionInfo;
}
);
export type LocalAttachmentV2Type = Readonly<{
version: 2;
@ -259,13 +281,6 @@ export type AttachmentDraftType =
size: number;
};
export type ThumbnailType = AttachmentType & {
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
// Whether the thumbnail has been copied from the original (quoted) message
copied?: boolean;
};
export enum AttachmentVariant {
Default = 'Default',
ThumbnailFromBackup = 'thumbnailFromBackup',
@ -1008,6 +1023,10 @@ export const isFile = (attachment: AttachmentType): boolean => {
return false;
}
if (MIME.isLongMessage(contentType)) {
return false;
}
return true;
};

View file

@ -4,7 +4,6 @@
import type { DurationInSeconds } from '../util/durations';
import type { AttachmentType } from './Attachment';
import type { EmbeddedContactType } from './EmbeddedContact';
import type { IndexableBoolean, IndexablePresence } from './IndexedDB';
export function getMentionsRegex(): RegExp {
return /\uFFFC/g;
@ -34,7 +33,6 @@ export type IncomingMessage = Readonly<
source?: string;
sourceDevice?: number;
} & SharedMessageProperties &
MessageSchemaVersion5 &
MessageSchemaVersion6 &
ExpirationTimerUpdate
>;
@ -56,7 +54,6 @@ export type OutgoingMessage = Readonly<
isViewOnce?: number;
synced: boolean;
} & SharedMessageProperties &
MessageSchemaVersion5 &
ExpirationTimerUpdate
>;
@ -64,7 +61,6 @@ export type VerifiedChangeMessage = Readonly<
{
type: 'verified-change';
} & SharedMessageProperties &
MessageSchemaVersion5 &
ExpirationTimerUpdate
>;
@ -72,7 +68,6 @@ export type ProfileChangeNotificationMessage = Readonly<
{
type: 'profile-change';
} & SharedMessageProperties &
MessageSchemaVersion5 &
ExpirationTimerUpdate
>;
@ -92,14 +87,6 @@ export type ExpirationTimerUpdate = Partial<
}>
>;
export type MessageSchemaVersion5 = Partial<
Readonly<{
hasAttachments: IndexableBoolean;
hasVisualMediaAttachments: IndexablePresence;
hasFileAttachments: IndexablePresence;
}>
>;
export type MessageSchemaVersion6 = Partial<
Readonly<{
contact: Array<EmbeddedContactType>;

View file

@ -23,7 +23,6 @@ import {
} from './Attachment';
import * as Errors from './errors';
import * as SchemaVersion from './SchemaVersion';
import { initializeAttachmentMetadata } from './message/initializeAttachmentMetadata';
import { LONG_MESSAGE } from './MIME';
import type * as MIME from './MIME';
@ -140,6 +139,8 @@ export type ContextType = {
// - Attachments: write bodyAttachment to disk
// Version 14
// - All attachments: ensure they are reencryptable to a known digest
// Version 15
// - A noop migration to cause attachments to be normalized when the message is saved
const INITIAL_SCHEMA_VERSION = 0;
@ -488,12 +489,10 @@ const toVersion6 = _withSchemaVersion({
schemaVersion: 6,
upgrade: _mapContact(Contact.parseAndWriteAvatar(migrateDataToFileSystem)),
});
// IMPORTANT: Weve updated our definition of `initializeAttachmentMetadata`, so
// we need to run it again on existing items that have previously been incorrectly
// classified:
// NOOP: hasFileAttachments, etc. is now computed at message save time
const toVersion7 = _withSchemaVersion({
schemaVersion: 7,
upgrade: initializeAttachmentMetadata,
upgrade: noopUpgrade,
});
const toVersion8 = _withSchemaVersion({
@ -655,6 +654,7 @@ const toVersion12 = _withSchemaVersion({
return result;
},
});
const toVersion13 = _withSchemaVersion({
schemaVersion: 13,
upgrade: migrateBodyAttachmentToDisk,

View file

@ -31,6 +31,7 @@ import { drop } from '../util/drop';
import { isNotNil } from '../util/isNotNil';
import { encryptLegacyAttachment } from '../util/encryptLegacyAttachment';
import { AttachmentDisposition } from '../util/getLocalAttachmentUrl';
import { getPlaintextHashForInMemoryAttachment } from '../AttachmentCrypto';
export type ActionSourceType =
| 'startup'
@ -1094,7 +1095,6 @@ export async function copyStickerToAttachments(
// Fall-back
contentType: IMAGE_WEBP,
};
const data = await window.Signal.Migrations.readAttachmentData(newSticker);
const sniffedMimeType = sniffImageMimeType(data);
@ -1106,6 +1106,8 @@ export async function copyStickerToAttachments(
);
}
newSticker.plaintextHash = getPlaintextHashForInMemoryAttachment(data);
return newSticker;
}

View file

@ -113,6 +113,9 @@ export type JSONWithUnknownFields<Value> =
export type WithRequiredProperties<T, P extends keyof T> = Omit<T, P> &
Required<Pick<T, P>>;
export type WithOptionalProperties<T, P extends keyof T> = Omit<T, P> &
Partial<Pick<T, P>>;
export function getTypingIndicatorSetting(): boolean {
return window.storage.get('typingIndicators', false);
}

View file

@ -1,48 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Attachment from '../Attachment';
import * as IndexedDB from '../IndexedDB';
import type { MessageAttributesType } from '../../model-types.d';
const hasAttachment =
(predicate: (value: Attachment.AttachmentType) => boolean) =>
(message: MessageAttributesType): IndexedDB.IndexablePresence =>
IndexedDB.toIndexablePresence((message.attachments || []).some(predicate));
const hasFileAttachment = hasAttachment(Attachment.isFile);
const hasVisualMediaAttachment = hasAttachment(Attachment.isVisualMedia);
export const initializeAttachmentMetadata = async (
message: MessageAttributesType
): Promise<MessageAttributesType> => {
if (message.type === 'verified-change') {
return message;
}
if (message.type === 'profile-change') {
return message;
}
if (message.messageTimer || message.isViewOnce) {
return message;
}
const attachments = (message.attachments || []).filter(
(attachment: Attachment.AttachmentType) =>
attachment.contentType !== 'text/x-signal-plain'
);
const hasAttachments = IndexedDB.toIndexableBoolean(attachments.length > 0);
const hasFileAttachments = hasFileAttachment({ ...message, attachments });
const hasVisualMediaAttachments = hasVisualMediaAttachment({
...message,
attachments,
});
return {
...message,
hasAttachments,
hasFileAttachments,
hasVisualMediaAttachments,
};
};

View file

@ -4,6 +4,9 @@
export type NullToUndefined<T> =
Extract<T, null> extends never ? T : Exclude<T, null> | undefined;
export type UndefinedToNull<T> =
Extract<T, undefined> extends never ? T : Exclude<T, undefined> | null;
export function dropNull<T>(
value: NonNullable<T> | null | undefined
): T | undefined {
@ -35,3 +38,23 @@ export function shallowDropNull<O extends { [key: string]: any }>(
return result;
}
export function convertUndefinedToNull<T>(value: T | undefined): T | null {
if (value === undefined) {
return null;
}
return value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function shallowConvertUndefinedToNull<T extends { [key: string]: any }>(
obj: T
): { [P in keyof T]: UndefinedToNull<T[P]> } {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = {};
for (const [key, propertyValue] of Object.entries(obj)) {
result[key] = convertUndefinedToNull(propertyValue);
}
return result;
}

View file

@ -84,7 +84,7 @@ export async function getQuoteAttachment(
thumbnail && thumbnail.path
? {
...(await loadAttachmentData(thumbnail)),
objectUrl: getLocalAttachmentUrl(thumbnail),
url: getLocalAttachmentUrl(thumbnail),
}
: undefined,
};
@ -123,7 +123,7 @@ export async function getQuoteAttachment(
thumbnail: path
? {
...(await loadAttachmentData(sticker.data)),
objectUrl: getLocalAttachmentUrl(sticker.data),
url: getLocalAttachmentUrl(sticker.data),
}
: undefined,
},