Normalize message attachments
This commit is contained in:
parent
8d8e0329cf
commit
d6e81eee11
39 changed files with 2540 additions and 807 deletions
|
@ -273,11 +273,13 @@ type DeleteOrphanedAttachmentsOptionsType = Readonly<{
|
||||||
type CleanupOrphanedAttachmentsOptionsType = Readonly<{
|
type CleanupOrphanedAttachmentsOptionsType = Readonly<{
|
||||||
sql: MainSQL;
|
sql: MainSQL;
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
|
_block?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
async function cleanupOrphanedAttachments({
|
async function cleanupOrphanedAttachments({
|
||||||
sql,
|
sql,
|
||||||
userDataPath,
|
userDataPath,
|
||||||
|
_block = false,
|
||||||
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
|
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
|
||||||
await deleteAllBadges({
|
await deleteAllBadges({
|
||||||
userDataPath,
|
userDataPath,
|
||||||
|
@ -304,8 +306,6 @@ async function cleanupOrphanedAttachments({
|
||||||
attachments: orphanedDraftAttachments,
|
attachments: orphanedDraftAttachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete orphaned attachments from conversations and messages.
|
|
||||||
|
|
||||||
const orphanedAttachments = new Set(await getAllAttachments(userDataPath));
|
const orphanedAttachments = new Set(await getAllAttachments(userDataPath));
|
||||||
console.log(
|
console.log(
|
||||||
'cleanupOrphanedAttachments: found ' +
|
'cleanupOrphanedAttachments: found ' +
|
||||||
|
@ -319,21 +319,27 @@ async function cleanupOrphanedAttachments({
|
||||||
);
|
);
|
||||||
|
|
||||||
{
|
{
|
||||||
const attachments: Array<string> = await sql.sqlRead(
|
const conversationAttachments: Array<string> = await sql.sqlRead(
|
||||||
'getKnownConversationAttachments'
|
'getKnownConversationAttachments'
|
||||||
);
|
);
|
||||||
|
|
||||||
let missing = 0;
|
let missingConversationAttachments = 0;
|
||||||
for (const known of attachments) {
|
for (const known of conversationAttachments) {
|
||||||
if (!orphanedAttachments.delete(known)) {
|
if (!orphanedAttachments.delete(known)) {
|
||||||
missing += 1;
|
missingConversationAttachments += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`cleanupOrphanedAttachments: found ${attachments.length} conversation ` +
|
`cleanupOrphanedAttachments: Got ${conversationAttachments.length} conversation attachments,` +
|
||||||
`attachments (${missing} missing), ${orphanedAttachments.size} remain`
|
` ${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(
|
console.log(
|
||||||
`cleanupOrphanedAttachments: found ${downloads.length} downloads ` +
|
`cleanupOrphanedAttachments: found ${downloads.length} known downloads, ` +
|
||||||
`(${missing} missing), ${orphanedDownloads.size} remain`
|
`${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
|
// 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
|
// 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.
|
// are saved to disk, but not put into any message or conversation model yet.
|
||||||
deleteOrphanedAttachments({
|
const deletePromise = deleteOrphanedAttachments({
|
||||||
orphanedAttachments,
|
orphanedAttachments,
|
||||||
orphanedDownloads,
|
orphanedDownloads,
|
||||||
sql,
|
sql,
|
||||||
userDataPath,
|
userDataPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_block) {
|
||||||
|
await deletePromise;
|
||||||
|
} else {
|
||||||
|
drop(deletePromise);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOrphanedAttachments({
|
function deleteOrphanedAttachments({
|
||||||
|
@ -368,14 +386,14 @@ function deleteOrphanedAttachments({
|
||||||
orphanedDownloads,
|
orphanedDownloads,
|
||||||
sql,
|
sql,
|
||||||
userDataPath,
|
userDataPath,
|
||||||
}: DeleteOrphanedAttachmentsOptionsType): void {
|
}: DeleteOrphanedAttachmentsOptionsType): Promise<void> {
|
||||||
// This function *can* throw.
|
// This function *can* throw.
|
||||||
async function runWithPossibleException(): Promise<void> {
|
async function runWithPossibleException(): Promise<void> {
|
||||||
let cursor: MessageAttachmentsCursorType | undefined;
|
let cursor: MessageAttachmentsCursorType | undefined;
|
||||||
let totalFound = 0;
|
let totalAttachmentsFound = 0;
|
||||||
let totalMissing = 0;
|
let totalMissing = 0;
|
||||||
let totalDownloadsFound = 0;
|
let totalDownloadsFound = 0;
|
||||||
let totalDownloadsMissing = 0;
|
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
let attachments: ReadonlyArray<string>;
|
let attachments: ReadonlyArray<string>;
|
||||||
|
@ -387,7 +405,7 @@ function deleteOrphanedAttachments({
|
||||||
cursor
|
cursor
|
||||||
));
|
));
|
||||||
|
|
||||||
totalFound += attachments.length;
|
totalAttachmentsFound += attachments.length;
|
||||||
totalDownloadsFound += downloads.length;
|
totalDownloadsFound += downloads.length;
|
||||||
|
|
||||||
for (const known of attachments) {
|
for (const known of attachments) {
|
||||||
|
@ -397,9 +415,7 @@ function deleteOrphanedAttachments({
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const known of downloads) {
|
for (const known of downloads) {
|
||||||
if (!orphanedDownloads.delete(known)) {
|
orphanedDownloads.delete(known);
|
||||||
totalDownloadsMissing += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor === undefined) {
|
if (cursor === undefined) {
|
||||||
|
@ -418,11 +434,16 @@ function deleteOrphanedAttachments({
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`cleanupOrphanedAttachments: found ${totalFound} message ` +
|
`cleanupOrphanedAttachments: ${totalAttachmentsFound} message ` +
|
||||||
`attachments, (${totalMissing} missing) ` +
|
`attachments; ${orphanedAttachments.size} remain`
|
||||||
`${orphanedAttachments.size} remain`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (totalMissing > 0) {
|
||||||
|
console.warn(
|
||||||
|
`cleanupOrphanedAttachments: ${totalMissing} message attachments were not found on disk`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await deleteAllAttachments({
|
await deleteAllAttachments({
|
||||||
userDataPath,
|
userDataPath,
|
||||||
attachments: Array.from(orphanedAttachments),
|
attachments: Array.from(orphanedAttachments),
|
||||||
|
@ -430,7 +451,6 @@ function deleteOrphanedAttachments({
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`cleanupOrphanedAttachments: found ${totalDownloadsFound} downloads ` +
|
`cleanupOrphanedAttachments: found ${totalDownloadsFound} downloads ` +
|
||||||
`(${totalDownloadsMissing} missing) ` +
|
|
||||||
`${orphanedDownloads.size} remain`
|
`${orphanedDownloads.size} remain`
|
||||||
);
|
);
|
||||||
await deleteAllDownloads({
|
await deleteAllDownloads({
|
||||||
|
@ -454,8 +474,7 @@ function deleteOrphanedAttachments({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentionally not awaiting
|
return runSafe();
|
||||||
void runSafe();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsDir: string | undefined;
|
let attachmentsDir: string | undefined;
|
||||||
|
@ -505,12 +524,19 @@ export function initialize({
|
||||||
rmSync(downloadsDir, { recursive: true, force: true });
|
rmSync(downloadsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => {
|
ipcMain.handle(
|
||||||
|
CLEANUP_ORPHANED_ATTACHMENTS_KEY,
|
||||||
|
async (_event, { _block }) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await cleanupOrphanedAttachments({ sql, userDataPath: configDir });
|
await cleanupOrphanedAttachments({
|
||||||
|
sql,
|
||||||
|
userDataPath: configDir,
|
||||||
|
_block,
|
||||||
|
});
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
|
console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle(CLEANUP_DOWNLOADS_KEY, async () => {
|
ipcMain.handle(CLEANUP_DOWNLOADS_KEY, async () => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
|
@ -81,6 +81,7 @@ export type ReencryptedAttachmentV2 = {
|
||||||
localKey: string;
|
localKey: string;
|
||||||
isReencryptableToSameDigest: boolean;
|
isReencryptableToSameDigest: boolean;
|
||||||
version: 2;
|
version: 2;
|
||||||
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReencryptionInfo = {
|
export type ReencryptionInfo = {
|
||||||
|
@ -583,7 +584,7 @@ export async function decryptAttachmentV2ToSink(
|
||||||
export async function decryptAndReencryptLocally(
|
export async function decryptAndReencryptLocally(
|
||||||
options: DecryptAttachmentOptionsType
|
options: DecryptAttachmentOptionsType
|
||||||
): Promise<ReencryptedAttachmentV2> {
|
): Promise<ReencryptedAttachmentV2> {
|
||||||
const { idForLogging } = options;
|
const { idForLogging, size } = options;
|
||||||
const logId = `reencryptAttachmentV2(${idForLogging})`;
|
const logId = `reencryptAttachmentV2(${idForLogging})`;
|
||||||
|
|
||||||
// Create random output file
|
// Create random output file
|
||||||
|
@ -622,6 +623,7 @@ export async function decryptAndReencryptLocally(
|
||||||
plaintextHash: result.plaintextHash,
|
plaintextHash: result.plaintextHash,
|
||||||
isReencryptableToSameDigest: result.isReencryptableToSameDigest,
|
isReencryptableToSameDigest: result.isReencryptableToSameDigest,
|
||||||
version: 2,
|
version: 2,
|
||||||
|
size,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|
|
@ -913,6 +913,7 @@ export function MixedContentTypes(args: Props): JSX.Element {
|
||||||
screenshot: {
|
screenshot: {
|
||||||
height: 112,
|
height: 112,
|
||||||
width: 112,
|
width: 112,
|
||||||
|
size: 128000,
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
contentType: IMAGE_JPEG,
|
contentType: IMAGE_JPEG,
|
||||||
path: 'originalpath',
|
path: 'originalpath',
|
||||||
|
|
|
@ -244,7 +244,7 @@ ImageOnly.args = {
|
||||||
width: 100,
|
width: 100,
|
||||||
size: 100,
|
size: 100,
|
||||||
path: pngUrl,
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
url: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -261,7 +261,7 @@ ImageAttachment.args = {
|
||||||
width: 100,
|
width: 100,
|
||||||
size: 100,
|
size: 100,
|
||||||
path: pngUrl,
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
url: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -331,7 +331,7 @@ VideoOnly.args = {
|
||||||
width: 100,
|
width: 100,
|
||||||
size: 100,
|
size: 100,
|
||||||
path: pngUrl,
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
url: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
text: undefined,
|
text: undefined,
|
||||||
|
@ -349,7 +349,7 @@ VideoAttachment.args = {
|
||||||
width: 100,
|
width: 100,
|
||||||
size: 100,
|
size: 100,
|
||||||
path: pngUrl,
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
url: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -588,7 +588,7 @@ IsStoryReplyEmoji.args = {
|
||||||
width: 100,
|
width: 100,
|
||||||
size: 100,
|
size: 100,
|
||||||
path: pngUrl,
|
path: pngUrl,
|
||||||
objectUrl: pngUrl,
|
url: pngUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reactionEmoji: '🏋️',
|
reactionEmoji: '🏋️',
|
||||||
|
|
|
@ -33,7 +33,10 @@ import type { QuotedAttachmentType } from '../../model-types';
|
||||||
|
|
||||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
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'>;
|
Pick<AttachmentType, 'isVoiceMessage' | 'fileName' | 'textAttachment'>;
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
@ -101,7 +104,7 @@ function getUrl(thumbnail?: ThumbnailType): string | undefined {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return thumbnail.objectUrl || thumbnail.url;
|
return thumbnail.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeLabel({
|
function getTypeLabel({
|
||||||
|
|
|
@ -46,7 +46,7 @@ import {
|
||||||
isImageTypeSupported,
|
isImageTypeSupported,
|
||||||
isVideoTypeSupported,
|
isVideoTypeSupported,
|
||||||
} from '../util/GoogleChrome';
|
} from '../util/GoogleChrome';
|
||||||
import type { MIMEType } from '../types/MIME';
|
import { IMAGE_JPEG, type MIMEType } from '../types/MIME';
|
||||||
import { AttachmentDownloadSource } from '../sql/Interface';
|
import { AttachmentDownloadSource } from '../sql/Interface';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
import {
|
import {
|
||||||
|
@ -64,6 +64,7 @@ import {
|
||||||
isPermanentlyUndownloadableWithoutBackfill,
|
isPermanentlyUndownloadableWithoutBackfill,
|
||||||
} from './helpers/attachmentBackfill';
|
} from './helpers/attachmentBackfill';
|
||||||
import { formatCountForLogging } from '../logging/formatCountForLogging';
|
import { formatCountForLogging } from '../logging/formatCountForLogging';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
|
||||||
export { isPermanentlyUndownloadable };
|
export { isPermanentlyUndownloadable };
|
||||||
|
|
||||||
|
@ -648,7 +649,10 @@ export async function runDownloadAttachmentJobInner({
|
||||||
attachmentWithThumbnail,
|
attachmentWithThumbnail,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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 = {
|
const attachmentWithThumbnail = {
|
||||||
...attachment,
|
...attachment,
|
||||||
thumbnailFromBackup: downloadedThumbnail,
|
thumbnailFromBackup: {
|
||||||
|
contentType: IMAGE_JPEG,
|
||||||
|
...downloadedThumbnail,
|
||||||
|
size: calculatedSize,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return attachmentWithThumbnail;
|
return attachmentWithThumbnail;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright 2019 Signal Messenger, LLC
|
// Copyright 2019 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
import { omit } from 'lodash';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload';
|
import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload';
|
||||||
|
@ -340,7 +341,7 @@ export async function addAttachmentToMessage(
|
||||||
if (thumbnail !== newThumbnail) {
|
if (thumbnail !== newThumbnail) {
|
||||||
handledInEditHistory = true;
|
handledInEditHistory = true;
|
||||||
}
|
}
|
||||||
return { ...item, thumbnail: newThumbnail };
|
return { ...item, thumbnail: omit(newThumbnail, 'thumbnail') };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -362,7 +363,7 @@ export async function addAttachmentToMessage(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
thumbnail: maybeReplaceAttachment(thumbnail),
|
thumbnail: maybeReplaceAttachment(omit(thumbnail, 'thumbnail')),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,8 +13,8 @@ import { strictAssert } from '../util/assert';
|
||||||
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
||||||
import { isQuoteAMatch, messageHasPaymentEvent } from './helpers';
|
import { isQuoteAMatch, messageHasPaymentEvent } from './helpers';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { isDownloadable } from '../types/Attachment';
|
|
||||||
import type { MessageModel } from '../models/messages';
|
import type { MessageModel } from '../models/messages';
|
||||||
|
import { isDownloadable } from '../types/Attachment';
|
||||||
|
|
||||||
export type MinimalMessageCache = Readonly<{
|
export type MinimalMessageCache = Readonly<{
|
||||||
findBySentAt(
|
findBySentAt(
|
||||||
|
@ -77,7 +77,7 @@ export const copyQuoteContentFromOriginal = async (
|
||||||
{ messageCache = window.MessageCache }: CopyQuoteOptionsType = {}
|
{ messageCache = window.MessageCache }: CopyQuoteOptionsType = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const { attachments } = quote;
|
const { attachments } = quote;
|
||||||
const firstAttachment = attachments ? attachments[0] : undefined;
|
const quoteAttachment = attachments ? attachments[0] : undefined;
|
||||||
|
|
||||||
if (messageHasPaymentEvent(message.attributes)) {
|
if (messageHasPaymentEvent(message.attributes)) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
@ -125,7 +125,7 @@ export const copyQuoteContentFromOriginal = async (
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
quote.bodyRanges = message.attributes.bodyRanges;
|
quote.bodyRanges = message.attributes.bodyRanges;
|
||||||
|
|
||||||
if (!firstAttachment || !firstAttachment.contentType) {
|
if (!quoteAttachment || !quoteAttachment.contentType) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,17 +150,17 @@ export const copyQuoteContentFromOriginal = async (
|
||||||
|
|
||||||
if (queryAttachments.length > 0) {
|
if (queryAttachments.length > 0) {
|
||||||
const queryFirst = queryAttachments[0];
|
const queryFirst = queryAttachments[0];
|
||||||
const { thumbnail } = queryFirst;
|
const { thumbnail: quotedThumbnail } = queryFirst;
|
||||||
|
|
||||||
if (thumbnail && thumbnail.path) {
|
if (quotedThumbnail && quotedThumbnail.path) {
|
||||||
firstAttachment.thumbnail = {
|
quoteAttachment.thumbnail = {
|
||||||
...thumbnail,
|
...quotedThumbnail,
|
||||||
copied: true,
|
copied: true,
|
||||||
};
|
};
|
||||||
} else if (!firstAttachment.thumbnail || !isDownloadable(queryFirst)) {
|
} else if (!quoteAttachment.thumbnail || !isDownloadable(queryFirst)) {
|
||||||
firstAttachment.contentType = queryFirst.contentType;
|
quoteAttachment.contentType = queryFirst.contentType;
|
||||||
firstAttachment.fileName = queryFirst.fileName;
|
quoteAttachment.fileName = queryFirst.fileName;
|
||||||
firstAttachment.thumbnail = undefined;
|
quoteAttachment.thumbnail = undefined;
|
||||||
} else {
|
} else {
|
||||||
// there is a thumbnail, but the original message attachment has not been
|
// there is a thumbnail, but the original message attachment has not been
|
||||||
// downloaded yet, so we leave the quote attachment as is for now
|
// downloaded yet, so we leave the quote attachment as is for now
|
||||||
|
@ -168,19 +168,17 @@ export const copyQuoteContentFromOriginal = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryPreview.length > 0) {
|
if (queryPreview.length > 0) {
|
||||||
const queryFirst = queryPreview[0];
|
const { image: quotedPreviewImage } = queryPreview[0];
|
||||||
const { image } = queryFirst;
|
if (quotedPreviewImage && quotedPreviewImage.path) {
|
||||||
|
quoteAttachment.thumbnail = {
|
||||||
if (image && image.path) {
|
...quotedPreviewImage,
|
||||||
firstAttachment.thumbnail = {
|
|
||||||
...image,
|
|
||||||
copied: true,
|
copied: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sticker && sticker.data && sticker.data.path) {
|
if (sticker && sticker.data && sticker.data.path) {
|
||||||
firstAttachment.thumbnail = {
|
quoteAttachment.thumbnail = {
|
||||||
...sticker.data,
|
...sticker.data,
|
||||||
copied: true,
|
copied: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -554,9 +554,6 @@ export async function handleDataMessage(
|
||||||
errors: [],
|
errors: [],
|
||||||
flags: dataMessage.flags,
|
flags: dataMessage.flags,
|
||||||
giftBadge: initialMessage.giftBadge,
|
giftBadge: initialMessage.giftBadge,
|
||||||
hasAttachments: dataMessage.hasAttachments,
|
|
||||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
|
||||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
|
||||||
isViewOnce: Boolean(dataMessage.isViewOnce),
|
isViewOnce: Boolean(dataMessage.isViewOnce),
|
||||||
mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => {
|
mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => {
|
||||||
if (!BodyRange.isMention(bodyRange)) {
|
if (!BodyRange.isMention(bodyRange)) {
|
||||||
|
|
11
ts/model-types.d.ts
vendored
11
ts/model-types.d.ts
vendored
|
@ -15,11 +15,7 @@ import type { ReadStatus } from './messages/MessageReadStatus';
|
||||||
import type { SendStateByConversationId } from './messages/MessageSendState';
|
import type { SendStateByConversationId } from './messages/MessageSendState';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
|
||||||
|
|
||||||
import type {
|
import type { AttachmentDraftType, AttachmentType } from './types/Attachment';
|
||||||
AttachmentDraftType,
|
|
||||||
AttachmentType,
|
|
||||||
ThumbnailType,
|
|
||||||
} from './types/Attachment';
|
|
||||||
import type { EmbeddedContactType } from './types/EmbeddedContact';
|
import type { EmbeddedContactType } from './types/EmbeddedContact';
|
||||||
import { SignalService as Proto } from './protobuf';
|
import { SignalService as Proto } from './protobuf';
|
||||||
import type { AvatarDataType, ContactAvatarType } from './types/Avatar';
|
import type { AvatarDataType, ContactAvatarType } from './types/Avatar';
|
||||||
|
@ -82,7 +78,7 @@ export type GroupMigrationType = {
|
||||||
export type QuotedAttachmentType = {
|
export type QuotedAttachmentType = {
|
||||||
contentType: MIMEType;
|
contentType: MIMEType;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
thumbnail?: ThumbnailType;
|
thumbnail?: AttachmentType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuotedMessageType = {
|
export type QuotedMessageType = {
|
||||||
|
@ -186,9 +182,6 @@ export type MessageAttributesType = {
|
||||||
expireTimer?: DurationInSeconds;
|
expireTimer?: DurationInSeconds;
|
||||||
groupMigration?: GroupMigrationType;
|
groupMigration?: GroupMigrationType;
|
||||||
group_update?: GroupV1Update;
|
group_update?: GroupV1Update;
|
||||||
hasAttachments?: boolean | 0 | 1;
|
|
||||||
hasFileAttachments?: boolean | 0 | 1;
|
|
||||||
hasVisualMediaAttachments?: boolean | 0 | 1;
|
|
||||||
mentionsMe?: boolean | 0 | 1;
|
mentionsMe?: boolean | 0 | 1;
|
||||||
isErased?: boolean;
|
isErased?: boolean;
|
||||||
isTapToViewInvalid?: boolean;
|
isTapToViewInvalid?: boolean;
|
||||||
|
|
|
@ -49,7 +49,6 @@ import type {
|
||||||
ItemType,
|
ItemType,
|
||||||
StoredItemType,
|
StoredItemType,
|
||||||
MessageType,
|
MessageType,
|
||||||
MessageTypeUnhydrated,
|
|
||||||
PreKeyIdType,
|
PreKeyIdType,
|
||||||
PreKeyType,
|
PreKeyType,
|
||||||
StoredPreKeyType,
|
StoredPreKeyType,
|
||||||
|
@ -62,7 +61,6 @@ import type {
|
||||||
ClientOnlyReadableInterface,
|
ClientOnlyReadableInterface,
|
||||||
ClientOnlyWritableInterface,
|
ClientOnlyWritableInterface,
|
||||||
} from './Interface';
|
} from './Interface';
|
||||||
import { hydrateMessage } from './hydration';
|
|
||||||
import type { MessageAttributesType } from '../model-types';
|
import type { MessageAttributesType } from '../model-types';
|
||||||
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
||||||
|
|
||||||
|
@ -546,7 +544,6 @@ function handleSearchMessageJSON(
|
||||||
messages: Array<ServerSearchResultMessageType>
|
messages: Array<ServerSearchResultMessageType>
|
||||||
): Array<ClientSearchResultMessageType> {
|
): Array<ClientSearchResultMessageType> {
|
||||||
return messages.map<ClientSearchResultMessageType>(message => {
|
return messages.map<ClientSearchResultMessageType>(message => {
|
||||||
const parsedMessage = hydrateMessage(message);
|
|
||||||
assertDev(
|
assertDev(
|
||||||
message.ftsSnippet ?? typeof message.mentionStart === 'number',
|
message.ftsSnippet ?? typeof message.mentionStart === 'number',
|
||||||
'Neither ftsSnippet nor matching mention returned from message search'
|
'Neither ftsSnippet nor matching mention returned from message search'
|
||||||
|
@ -554,7 +551,7 @@ function handleSearchMessageJSON(
|
||||||
const snippet =
|
const snippet =
|
||||||
message.ftsSnippet ??
|
message.ftsSnippet ??
|
||||||
generateSnippetAroundMention({
|
generateSnippetAroundMention({
|
||||||
body: parsedMessage.body || '',
|
body: message.body || '',
|
||||||
mentionStart: message.mentionStart ?? 0,
|
mentionStart: message.mentionStart ?? 0,
|
||||||
mentionLength: message.mentionLength ?? 1,
|
mentionLength: message.mentionLength ?? 1,
|
||||||
});
|
});
|
||||||
|
@ -562,7 +559,7 @@ function handleSearchMessageJSON(
|
||||||
return {
|
return {
|
||||||
// Empty array is a default value. `message.json` has the real field
|
// Empty array is a default value. `message.json` has the real field
|
||||||
bodyRanges: [],
|
bodyRanges: [],
|
||||||
...parsedMessage,
|
...message,
|
||||||
snippet,
|
snippet,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -629,15 +626,17 @@ async function saveMessages(
|
||||||
forceSave,
|
forceSave,
|
||||||
ourAci,
|
ourAci,
|
||||||
postSaveUpdates,
|
postSaveUpdates,
|
||||||
|
_testOnlyAvoidNormalizingAttachments,
|
||||||
}: {
|
}: {
|
||||||
forceSave?: boolean;
|
forceSave?: boolean;
|
||||||
ourAci: AciString;
|
ourAci: AciString;
|
||||||
postSaveUpdates: () => Promise<void>;
|
postSaveUpdates: () => Promise<void>;
|
||||||
|
_testOnlyAvoidNormalizingAttachments?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<Array<string>> {
|
): Promise<Array<string>> {
|
||||||
const result = await writableChannel.saveMessages(
|
const result = await writableChannel.saveMessages(
|
||||||
arrayOfMessages.map(message => _cleanMessageData(message)),
|
arrayOfMessages.map(message => _cleanMessageData(message)),
|
||||||
{ forceSave, ourAci }
|
{ forceSave, ourAci, _testOnlyAvoidNormalizingAttachments }
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(postSaveUpdates?.());
|
drop(postSaveUpdates?.());
|
||||||
|
@ -730,19 +729,13 @@ async function removeMessages(
|
||||||
await writableChannel.removeMessages(messageIds);
|
await writableChannel.removeMessages(messageIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessageJSON(
|
|
||||||
messages: Array<MessageTypeUnhydrated>
|
|
||||||
): Array<MessageType> {
|
|
||||||
return messages.map(message => hydrateMessage(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNewerMessagesByConversation(
|
async function getNewerMessagesByConversation(
|
||||||
options: AdjacentMessagesByConversationOptionsType
|
options: AdjacentMessagesByConversationOptionsType
|
||||||
): Promise<Array<MessageType>> {
|
): Promise<Array<MessageType>> {
|
||||||
const messages =
|
const messages =
|
||||||
await readableChannel.getNewerMessagesByConversation(options);
|
await readableChannel.getNewerMessagesByConversation(options);
|
||||||
|
|
||||||
return handleMessageJSON(messages);
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentStoryReplies(
|
async function getRecentStoryReplies(
|
||||||
|
@ -754,7 +747,7 @@ async function getRecentStoryReplies(
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleMessageJSON(messages);
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOlderMessagesByConversation(
|
async function getOlderMessagesByConversation(
|
||||||
|
@ -763,7 +756,7 @@ async function getOlderMessagesByConversation(
|
||||||
const messages =
|
const messages =
|
||||||
await readableChannel.getOlderMessagesByConversation(options);
|
await readableChannel.getOlderMessagesByConversation(options);
|
||||||
|
|
||||||
return handleMessageJSON(messages);
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConversationRangeCenteredOnMessage(
|
async function getConversationRangeCenteredOnMessage(
|
||||||
|
@ -772,11 +765,7 @@ async function getConversationRangeCenteredOnMessage(
|
||||||
const result =
|
const result =
|
||||||
await readableChannel.getConversationRangeCenteredOnMessage(options);
|
await readableChannel.getConversationRangeCenteredOnMessage(options);
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
...result,
|
|
||||||
older: handleMessageJSON(result.older),
|
|
||||||
newer: handleMessageJSON(result.newer),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMessagesInConversation(
|
async function removeMessagesInConversation(
|
||||||
|
@ -832,9 +821,13 @@ async function saveAttachmentDownloadJob(
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
|
||||||
async function cleanupOrphanedAttachments(): Promise<void> {
|
async function cleanupOrphanedAttachments({
|
||||||
|
_block = false,
|
||||||
|
}: {
|
||||||
|
_block?: boolean;
|
||||||
|
} = {}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invokeWithTimeout(CLEANUP_ORPHANED_ATTACHMENTS_KEY);
|
await invokeWithTimeout(CLEANUP_ORPHANED_ATTACHMENTS_KEY, { _block });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
'sql/Client: cleanupOrphanedAttachments failure',
|
'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(
|
return createTaskWithTimeout(
|
||||||
() => ipc.invoke(name),
|
() => ipc.invoke(name, ...args),
|
||||||
`callChannel call to ${name}`
|
`callChannel call to ${name}`
|
||||||
)();
|
)();
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,10 @@ import type {
|
||||||
CallLinkType,
|
CallLinkType,
|
||||||
DefunctCallLinkType,
|
DefunctCallLinkType,
|
||||||
} from '../types/CallLink';
|
} from '../types/CallLink';
|
||||||
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
|
import type {
|
||||||
|
AttachmentDownloadJobType,
|
||||||
|
AttachmentDownloadJobTypeType,
|
||||||
|
} from '../types/AttachmentDownload';
|
||||||
import type {
|
import type {
|
||||||
GroupSendEndorsementsData,
|
GroupSendEndorsementsData,
|
||||||
GroupSendMemberEndorsementRecord,
|
GroupSendMemberEndorsementRecord,
|
||||||
|
@ -46,6 +49,7 @@ import type { SyncTaskType } from '../util/syncTasks';
|
||||||
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
|
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
|
||||||
import type { GifType } from '../components/fun/panels/FunPanelGifs';
|
import type { GifType } from '../components/fun/panels/FunPanelGifs';
|
||||||
import type { NotificationProfileType } from '../types/NotificationProfile';
|
import type { NotificationProfileType } from '../types/NotificationProfile';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
|
||||||
export type ReadableDB = Database & { __readable_db: never };
|
export type ReadableDB = Database & { __readable_db: never };
|
||||||
export type WritableDB = ReadableDB & { __writable_db: never };
|
export type WritableDB = ReadableDB & { __writable_db: never };
|
||||||
|
@ -217,7 +221,10 @@ export type StoredPreKeyType = PreKeyType & {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
publicKey: 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
|
// If the FTS matches text in message.body, snippet will be populated
|
||||||
ftsSnippet: string | null;
|
ftsSnippet: string | null;
|
||||||
|
|
||||||
|
@ -537,6 +544,136 @@ export enum AttachmentDownloadSource {
|
||||||
BACKFILL = 'backfill',
|
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 = {
|
type ReadableInterface = {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
|
|
||||||
|
@ -744,6 +881,9 @@ type ReadableInterface = {
|
||||||
|
|
||||||
getStatisticsForLogging(): Record<string, string>;
|
getStatisticsForLogging(): Record<string, string>;
|
||||||
getSizeOfPendingBackupAttachmentDownloadJobs(): number;
|
getSizeOfPendingBackupAttachmentDownloadJobs(): number;
|
||||||
|
getAttachmentReferencesForMessages: (
|
||||||
|
messageIds: Array<string>
|
||||||
|
) => Array<MessageAttachmentDBType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WritableInterface = {
|
type WritableInterface = {
|
||||||
|
@ -866,6 +1006,7 @@ type WritableInterface = {
|
||||||
) => void;
|
) => void;
|
||||||
_removeAllReactions: () => void;
|
_removeAllReactions: () => void;
|
||||||
_removeAllMessages: () => void;
|
_removeAllMessages: () => void;
|
||||||
|
_removeMessage: (id: string) => void;
|
||||||
incrementMessagesMigrationAttempts: (
|
incrementMessagesMigrationAttempts: (
|
||||||
messageIds: ReadonlyArray<string>
|
messageIds: ReadonlyArray<string>
|
||||||
) => void;
|
) => void;
|
||||||
|
@ -1072,16 +1213,16 @@ export type ServerReadableDirectInterface = ReadableInterface & {
|
||||||
getRecentStoryReplies(
|
getRecentStoryReplies(
|
||||||
storyId: string,
|
storyId: string,
|
||||||
options?: GetRecentStoryRepliesOptionsType
|
options?: GetRecentStoryRepliesOptionsType
|
||||||
): Array<MessageTypeUnhydrated>;
|
): Array<MessageType>;
|
||||||
getOlderMessagesByConversation: (
|
getOlderMessagesByConversation: (
|
||||||
options: AdjacentMessagesByConversationOptionsType
|
options: AdjacentMessagesByConversationOptionsType
|
||||||
) => Array<MessageTypeUnhydrated>;
|
) => Array<MessageType>;
|
||||||
getNewerMessagesByConversation: (
|
getNewerMessagesByConversation: (
|
||||||
options: AdjacentMessagesByConversationOptionsType
|
options: AdjacentMessagesByConversationOptionsType
|
||||||
) => Array<MessageTypeUnhydrated>;
|
) => Array<MessageType>;
|
||||||
getConversationRangeCenteredOnMessage: (
|
getConversationRangeCenteredOnMessage: (
|
||||||
options: AdjacentMessagesByConversationOptionsType
|
options: AdjacentMessagesByConversationOptionsType
|
||||||
) => GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated>;
|
) => GetConversationRangeCenteredOnMessageResultType<MessageType>;
|
||||||
|
|
||||||
getIdentityKeyById: (
|
getIdentityKeyById: (
|
||||||
id: IdentityKeyIdType
|
id: IdentityKeyIdType
|
||||||
|
@ -1141,7 +1282,11 @@ export type ServerWritableDirectInterface = WritableInterface & {
|
||||||
) => string;
|
) => string;
|
||||||
saveMessages: (
|
saveMessages: (
|
||||||
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
|
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
|
||||||
options: { forceSave?: boolean; ourAci: AciString }
|
options: {
|
||||||
|
forceSave?: boolean;
|
||||||
|
ourAci: AciString;
|
||||||
|
_testOnlyAvoidNormalizingAttachments?: boolean;
|
||||||
|
}
|
||||||
) => Array<string>;
|
) => Array<string>;
|
||||||
saveMessagesIndividually: (
|
saveMessagesIndividually: (
|
||||||
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
|
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
|
||||||
|
@ -1241,6 +1386,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
|
||||||
forceSave?: boolean;
|
forceSave?: boolean;
|
||||||
ourAci: AciString;
|
ourAci: AciString;
|
||||||
postSaveUpdates: () => Promise<void>;
|
postSaveUpdates: () => Promise<void>;
|
||||||
|
_testOnlyAvoidNormalizingAttachments?: boolean;
|
||||||
}
|
}
|
||||||
) => string;
|
) => string;
|
||||||
saveMessages: (
|
saveMessages: (
|
||||||
|
@ -1249,6 +1395,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
|
||||||
forceSave?: boolean;
|
forceSave?: boolean;
|
||||||
ourAci: AciString;
|
ourAci: AciString;
|
||||||
postSaveUpdates: () => Promise<void>;
|
postSaveUpdates: () => Promise<void>;
|
||||||
|
_testOnlyAvoidNormalizingAttachments?: boolean;
|
||||||
}
|
}
|
||||||
) => Array<string>;
|
) => Array<string>;
|
||||||
saveMessagesIndividually: (
|
saveMessagesIndividually: (
|
||||||
|
@ -1311,7 +1458,7 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
removeOtherData: () => void;
|
removeOtherData: () => void;
|
||||||
cleanupOrphanedAttachments: () => void;
|
cleanupOrphanedAttachments: (options?: { _block: boolean }) => void;
|
||||||
ensureFilePermissions: () => void;
|
ensureFilePermissions: () => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
614
ts/sql/Server.ts
614
ts/sql/Server.ts
File diff suppressed because it is too large
Load diff
|
@ -1,10 +1,11 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
import type { ReadStatus } from '../messages/MessageReadStatus';
|
import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import type { SeenStatus } from '../MessageSeenStatus';
|
import type { SeenStatus } from '../MessageSeenStatus';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
import type { ServiceIdString } from '../types/ServiceId';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull, shallowDropNull } from '../util/dropNull';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
|
|
||||||
|
@ -12,7 +13,23 @@ import type {
|
||||||
MessageTypeUnhydrated,
|
MessageTypeUnhydrated,
|
||||||
MessageType,
|
MessageType,
|
||||||
MESSAGE_COLUMNS,
|
MESSAGE_COLUMNS,
|
||||||
|
ReadableDB,
|
||||||
|
MessageAttachmentDBType,
|
||||||
} from './Interface';
|
} 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 {
|
function toBoolean(value: number | null): boolean | undefined {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
|
@ -21,7 +38,27 @@ function toBoolean(value: number | null): boolean | undefined {
|
||||||
return value === 1;
|
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 {
|
const {
|
||||||
json,
|
json,
|
||||||
id,
|
id,
|
||||||
|
@ -29,9 +66,6 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
|
||||||
conversationId,
|
conversationId,
|
||||||
expirationStartTimestamp,
|
expirationStartTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
hasAttachments,
|
|
||||||
hasFileAttachments,
|
|
||||||
hasVisualMediaAttachments,
|
|
||||||
isErased,
|
isErased,
|
||||||
isViewOnce,
|
isViewOnce,
|
||||||
mentionsMe,
|
mentionsMe,
|
||||||
|
@ -63,9 +97,6 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
|
||||||
conversationId: conversationId || '',
|
conversationId: conversationId || '',
|
||||||
expirationStartTimestamp: dropNull(expirationStartTimestamp),
|
expirationStartTimestamp: dropNull(expirationStartTimestamp),
|
||||||
expireTimer: dropNull(expireTimer) as MessageType['expireTimer'],
|
expireTimer: dropNull(expireTimer) as MessageType['expireTimer'],
|
||||||
hasAttachments: toBoolean(hasAttachments),
|
|
||||||
hasFileAttachments: toBoolean(hasFileAttachments),
|
|
||||||
hasVisualMediaAttachments: toBoolean(hasVisualMediaAttachments),
|
|
||||||
isErased: toBoolean(isErased),
|
isErased: toBoolean(isErased),
|
||||||
isViewOnce: toBoolean(isViewOnce),
|
isViewOnce: toBoolean(isViewOnce),
|
||||||
mentionsMe: toBoolean(mentionsMe),
|
mentionsMe: toBoolean(mentionsMe),
|
||||||
|
@ -86,3 +117,299 @@ export function hydrateMessage(row: MessageTypeUnhydrated): MessageType {
|
||||||
unidentifiedDeliveryReceived: toBoolean(unidentifiedDeliveryReceived),
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -3,15 +3,11 @@
|
||||||
|
|
||||||
import { parentPort } from 'worker_threads';
|
import { parentPort } from 'worker_threads';
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { WrappedWorkerRequest, WrappedWorkerResponse } from './main';
|
||||||
import type {
|
|
||||||
WrappedWorkerRequest,
|
|
||||||
WrappedWorkerResponse,
|
|
||||||
WrappedWorkerLogEntry,
|
|
||||||
} from './main';
|
|
||||||
import type { WritableDB } from './Interface';
|
import type { WritableDB } from './Interface';
|
||||||
import { initialize, DataReader, DataWriter, removeDB } from './Server';
|
import { initialize, DataReader, DataWriter, removeDB } from './Server';
|
||||||
import { SqliteErrorKind, parseSqliteError } from './errors';
|
import { SqliteErrorKind, parseSqliteError } from './errors';
|
||||||
|
import { sqlLogger as logger } from './sqlLogger';
|
||||||
|
|
||||||
if (!parentPort) {
|
if (!parentPort) {
|
||||||
throw new Error('Must run as a worker thread');
|
throw new Error('Must run as a worker thread');
|
||||||
|
@ -31,39 +27,6 @@ function respond(seq: number, response?: any) {
|
||||||
port.postMessage(wrappedResponse);
|
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 db: WritableDB | undefined;
|
||||||
let isPrimary = false;
|
let isPrimary = false;
|
||||||
let isRemoved = false;
|
let isRemoved = false;
|
||||||
|
@ -79,7 +42,6 @@ const onMessage = (
|
||||||
db = initialize({
|
db = initialize({
|
||||||
...request.options,
|
...request.options,
|
||||||
isPrimary,
|
isPrimary,
|
||||||
logger,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
respond(seq, undefined);
|
respond(seq, undefined);
|
||||||
|
|
111
ts/sql/migrations/1360-attachments.ts
Normal file
111
ts/sql/migrations/1360-attachments.ts
Normal 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!');
|
||||||
|
}
|
|
@ -110,10 +110,11 @@ import { updateToSchemaVersion1310 } from './1310-muted-fixup';
|
||||||
import { updateToSchemaVersion1320 } from './1320-unprocessed-received-at-date';
|
import { updateToSchemaVersion1320 } from './1320-unprocessed-received-at-date';
|
||||||
import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index';
|
import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index';
|
||||||
import { updateToSchemaVersion1340 } from './1340-recent-gifs';
|
import { updateToSchemaVersion1340 } from './1340-recent-gifs';
|
||||||
|
import { updateToSchemaVersion1350 } from './1350-notification-profiles';
|
||||||
import {
|
import {
|
||||||
updateToSchemaVersion1350,
|
updateToSchemaVersion1360,
|
||||||
version as MAX_VERSION,
|
version as MAX_VERSION,
|
||||||
} from './1350-notification-profiles';
|
} from './1360-attachments';
|
||||||
|
|
||||||
import { DataWriter } from '../Server';
|
import { DataWriter } from '../Server';
|
||||||
|
|
||||||
|
@ -2102,6 +2103,7 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1330,
|
updateToSchemaVersion1330,
|
||||||
updateToSchemaVersion1340,
|
updateToSchemaVersion1340,
|
||||||
updateToSchemaVersion1350,
|
updateToSchemaVersion1350,
|
||||||
|
updateToSchemaVersion1360,
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DBVersionFromFutureError extends Error {
|
export class DBVersionFromFutureError extends Error {
|
||||||
|
|
45
ts/sql/sqlLogger.ts
Normal file
45
ts/sql/sqlLogger.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -328,8 +328,7 @@ function showLightbox(opts: {
|
||||||
sentAt,
|
sentAt,
|
||||||
},
|
},
|
||||||
attachment: item,
|
attachment: item,
|
||||||
thumbnailObjectUrl:
|
thumbnailObjectUrl: item.thumbnail?.path
|
||||||
item.thumbnail?.objectUrl || item.thumbnail?.path
|
|
||||||
? getLocalAttachmentUrl(item.thumbnail)
|
? getLocalAttachmentUrl(item.thumbnail)
|
||||||
: undefined,
|
: undefined,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
|
|
|
@ -1887,18 +1887,16 @@ export function getPropsForAttachment(
|
||||||
|
|
||||||
function processQuoteAttachment(attachment: QuotedAttachmentType) {
|
function processQuoteAttachment(attachment: QuotedAttachmentType) {
|
||||||
const { thumbnail } = attachment;
|
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 {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
isVoiceMessage: isVoiceMessage(attachment),
|
isVoiceMessage: isVoiceMessage(attachment),
|
||||||
thumbnail: thumbnailWithObjectUrl,
|
thumbnail: thumbnail?.path
|
||||||
|
? {
|
||||||
|
...thumbnail,
|
||||||
|
url: getLocalAttachmentUrl(thumbnail),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,7 @@ describe('backup/attachments', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await DataWriter.removeAll();
|
await DataWriter.removeAll();
|
||||||
window.storage.reset();
|
window.storage.reset();
|
||||||
|
|
||||||
window.ConversationController.reset();
|
window.ConversationController.reset();
|
||||||
|
|
||||||
await setupBasics();
|
await setupBasics();
|
||||||
|
@ -166,8 +167,6 @@ describe('backup/attachments', () => {
|
||||||
// path & iv will not be roundtripped
|
// path & iv will not be roundtripped
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
hasAttachments: true,
|
|
||||||
hasVisualMediaAttachments: true,
|
|
||||||
attachments: [
|
attachments: [
|
||||||
omit(longMessageAttachment, NON_ROUNDTRIPPED_FIELDS),
|
omit(longMessageAttachment, NON_ROUNDTRIPPED_FIELDS),
|
||||||
omit(normalAttachment, NON_ROUNDTRIPPED_FIELDS),
|
omit(normalAttachment, NON_ROUNDTRIPPED_FIELDS),
|
||||||
|
@ -284,8 +283,6 @@ describe('backup/attachments', () => {
|
||||||
// path & iv will not be roundtripped
|
// path & iv will not be roundtripped
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
hasAttachments: true,
|
|
||||||
hasVisualMediaAttachments: true,
|
|
||||||
attachments: [
|
attachments: [
|
||||||
omit(attachment1, NON_ROUNDTRIPPED_FIELDS),
|
omit(attachment1, NON_ROUNDTRIPPED_FIELDS),
|
||||||
omit(attachment2, NON_ROUNDTRIPPED_FIELDS),
|
omit(attachment2, NON_ROUNDTRIPPED_FIELDS),
|
||||||
|
@ -307,9 +304,6 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
hasAttachments: true,
|
|
||||||
hasVisualMediaAttachments: true,
|
|
||||||
|
|
||||||
// path, iv, and uploadTimestamp will not be roundtripped,
|
// path, iv, and uploadTimestamp will not be roundtripped,
|
||||||
// but there will be a backupLocator
|
// but there will be a backupLocator
|
||||||
attachments: [
|
attachments: [
|
||||||
|
@ -341,7 +335,6 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
hasAttachments: true,
|
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
|
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
|
||||||
|
@ -373,7 +366,6 @@ describe('backup/attachments', () => {
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
body: 'hello',
|
body: 'hello',
|
||||||
hasAttachments: true,
|
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
|
...omit(attachment, NON_ROUNDTRIPPED_BACKUP_LOCATOR_FIELDS),
|
||||||
|
@ -637,8 +629,6 @@ describe('backup/attachments', () => {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
...existingMessage,
|
...existingMessage,
|
||||||
hasAttachments: true,
|
|
||||||
hasVisualMediaAttachments: true,
|
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
...omit(
|
...omit(
|
||||||
|
|
|
@ -110,9 +110,6 @@ function sortAndNormalize(
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
// Defaults
|
// Defaults
|
||||||
hasAttachments: false,
|
|
||||||
hasFileAttachments: false,
|
|
||||||
hasVisualMediaAttachments: false,
|
|
||||||
isErased: false,
|
isErased: false,
|
||||||
isViewOnce: false,
|
isViewOnce: false,
|
||||||
mentionsMe: false,
|
mentionsMe: false,
|
||||||
|
|
259
ts/test-electron/cleanupOrphanedAttachments_test.ts
Normal file
259
ts/test-electron/cleanupOrphanedAttachments_test.ts
Normal 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']);
|
||||||
|
});
|
||||||
|
});
|
|
@ -64,9 +64,6 @@ describe('Conversations', () => {
|
||||||
body: 'bananas',
|
body: 'bananas',
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
expirationStartTimestamp: now,
|
expirationStartTimestamp: now,
|
||||||
hasAttachments: false,
|
|
||||||
hasFileAttachments: false,
|
|
||||||
hasVisualMediaAttachments: false,
|
|
||||||
id: generateUuid(),
|
id: generateUuid(),
|
||||||
received_at: now,
|
received_at: now,
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
|
|
608
ts/test-electron/normalizedAttachments_test.ts
Normal file
608
ts/test-electron/normalizedAttachments_test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -22,6 +22,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
|
||||||
import { MINUTE } from '../../util/durations';
|
import { MINUTE } from '../../util/durations';
|
||||||
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
|
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
|
import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment';
|
||||||
import { AttachmentDownloadSource } from '../../sql/Interface';
|
import { AttachmentDownloadSource } from '../../sql/Interface';
|
||||||
import { getAttachmentCiphertextLength } from '../../AttachmentCrypto';
|
import { getAttachmentCiphertextLength } from '../../AttachmentCrypto';
|
||||||
import { MEBIBYTE } from '../../types/AttachmentSize';
|
import { MEBIBYTE } from '../../types/AttachmentSize';
|
||||||
|
@ -507,14 +508,23 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
|
||||||
let processNewAttachment: sinon.SinonStub;
|
let processNewAttachment: sinon.SinonStub;
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
beforeEach(async () => {
|
const downloadedAttachment: Awaited<
|
||||||
sandbox = sinon.createSandbox();
|
ReturnType<typeof downloadAttachmentUtil>
|
||||||
downloadAttachment = sandbox.stub().returns({
|
> = {
|
||||||
path: '/path/to/file',
|
path: '/path/to/file',
|
||||||
iv: Buffer.alloc(16),
|
iv: 'iv',
|
||||||
plaintextHash: 'plaintextHash',
|
plaintextHash: 'plaintextHash',
|
||||||
isReencryptableToSameDigest: true,
|
isReencryptableToSameDigest: true,
|
||||||
});
|
localKey: 'localKey',
|
||||||
|
version: 2,
|
||||||
|
size: 128,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sandbox = sinon.createSandbox();
|
||||||
|
downloadAttachment = sandbox
|
||||||
|
.stub()
|
||||||
|
.returns(Promise.resolve(downloadedAttachment));
|
||||||
processNewAttachment = sandbox.stub().callsFake(attachment => attachment);
|
processNewAttachment = sandbox.stub().callsFake(attachment => attachment);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -611,6 +621,8 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
|
||||||
},
|
},
|
||||||
thumbnailFromBackup: {
|
thumbnailFromBackup: {
|
||||||
path: '/path/to/thumbnail',
|
path: '/path/to/thumbnail',
|
||||||
|
size: 128,
|
||||||
|
contentType: MIME.IMAGE_JPEG,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -724,11 +736,7 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
|
||||||
if (options.variant === AttachmentVariant.Default) {
|
if (options.variant === AttachmentVariant.Default) {
|
||||||
throw new Error('error while downloading');
|
throw new Error('error while downloading');
|
||||||
}
|
}
|
||||||
return {
|
return downloadedAttachment;
|
||||||
path: '/path/to/thumbnail',
|
|
||||||
iv: Buffer.alloc(16),
|
|
||||||
plaintextHash: 'plaintextHash',
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = composeJob({
|
const job = composeJob({
|
||||||
|
|
89
ts/test-node/sql/migration_1360_test.ts
Normal file
89
ts/test-node/sql/migration_1360_test.ts
Normal 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=?)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -197,9 +197,6 @@ describe('Message', () => {
|
||||||
fileName: 'test\uFFFDfig.exe',
|
fileName: 'test\uFFFDfig.exe',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hasAttachments: 1,
|
|
||||||
hasVisualMediaAttachments: undefined,
|
|
||||||
hasFileAttachments: undefined,
|
|
||||||
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
|
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -848,6 +845,7 @@ describe('Message', () => {
|
||||||
const result = await Message.upgradeSchema(message, {
|
const result = await Message.upgradeSchema(message, {
|
||||||
...getDefaultContext(),
|
...getDefaultContext(),
|
||||||
doesAttachmentExist: async () => false,
|
doesAttachmentExist: async () => false,
|
||||||
|
maxVersion: 14,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual({ ...message, schemaVersion: 14 }, result);
|
assert.deepEqual({ ...message, schemaVersion: 14 }, result);
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -114,7 +114,7 @@ export async function downloadAttachment(
|
||||||
variant: AttachmentVariant;
|
variant: AttachmentVariant;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
}
|
}
|
||||||
): Promise<ReencryptedAttachmentV2 & { size?: number }> {
|
): Promise<ReencryptedAttachmentV2> {
|
||||||
const logId = `downloadAttachment/${options.logPrefix ?? ''}`;
|
const logId = `downloadAttachment/${options.logPrefix ?? ''}`;
|
||||||
|
|
||||||
const { digest, incrementalMac, chunkSize, key, size } = attachment;
|
const { digest, incrementalMac, chunkSize, key, size } = attachment;
|
||||||
|
@ -272,8 +272,7 @@ export async function downloadAttachment(
|
||||||
// backup thumbnails don't get trimmed, so we just calculate the size as the
|
// backup thumbnails don't get trimmed, so we just calculate the size as the
|
||||||
// ciphertextSize, less IV and MAC
|
// ciphertextSize, less IV and MAC
|
||||||
const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH;
|
const calculatedSize = downloadSize - IV_LENGTH - MAC_LENGTH;
|
||||||
return {
|
return decryptAndReencryptLocally({
|
||||||
...(await decryptAndReencryptLocally({
|
|
||||||
type: 'backupThumbnail',
|
type: 'backupThumbnail',
|
||||||
ciphertextPath: cipherTextAbsolutePath,
|
ciphertextPath: cipherTextAbsolutePath,
|
||||||
idForLogging: logId,
|
idForLogging: logId,
|
||||||
|
@ -283,9 +282,7 @@ export async function downloadAttachment(
|
||||||
getBackupThumbnailOuterEncryptionKeyMaterial(attachment),
|
getBackupThumbnailOuterEncryptionKeyMaterial(attachment),
|
||||||
getAbsoluteAttachmentPath:
|
getAbsoluteAttachmentPath:
|
||||||
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||||
})),
|
});
|
||||||
size: calculatedSize,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw missingCaseError(options.variant);
|
throw missingCaseError(options.variant);
|
||||||
|
|
|
@ -24,7 +24,11 @@ import {
|
||||||
isImageTypeSupported,
|
isImageTypeSupported,
|
||||||
isVideoTypeSupported,
|
isVideoTypeSupported,
|
||||||
} from '../util/GoogleChrome';
|
} from '../util/GoogleChrome';
|
||||||
import type { LocalizerType, WithRequiredProperties } from './Util';
|
import type {
|
||||||
|
LocalizerType,
|
||||||
|
WithOptionalProperties,
|
||||||
|
WithRequiredProperties,
|
||||||
|
} from './Util';
|
||||||
import { ThemeType } from './Util';
|
import { ThemeType } from './Util';
|
||||||
import * as GoogleChrome from '../util/GoogleChrome';
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
@ -56,14 +60,47 @@ export class AttachmentPermanentlyUndownloadableError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScreenshotType = Omit<AttachmentType, 'size'> & {
|
export type ThumbnailType = EphemeralAttachmentFields & {
|
||||||
height: number;
|
size: number;
|
||||||
width: number;
|
contentType: MIME.MIMEType;
|
||||||
path: string;
|
path?: string;
|
||||||
size?: number;
|
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;
|
error?: boolean;
|
||||||
blurHash?: string;
|
blurHash?: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
@ -73,36 +110,27 @@ export type AttachmentType = {
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
plaintextHash?: string;
|
plaintextHash?: string;
|
||||||
uploadTimestamp?: number;
|
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;
|
size: number;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
path?: string;
|
path?: string;
|
||||||
screenshot?: ScreenshotType;
|
screenshot?: ScreenshotType;
|
||||||
screenshotData?: Uint8Array;
|
|
||||||
// Legacy Draft
|
|
||||||
screenshotPath?: string;
|
|
||||||
flags?: number;
|
flags?: number;
|
||||||
thumbnail?: ThumbnailType;
|
thumbnail?: ThumbnailType;
|
||||||
isCorrupted?: boolean;
|
isCorrupted?: boolean;
|
||||||
cdnNumber?: number;
|
cdnNumber?: number;
|
||||||
cdnId?: string;
|
|
||||||
cdnKey?: string;
|
cdnKey?: string;
|
||||||
downloadPath?: string;
|
downloadPath?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
iv?: string;
|
iv?: string;
|
||||||
data?: Uint8Array;
|
|
||||||
textAttachment?: TextAttachmentType;
|
textAttachment?: TextAttachmentType;
|
||||||
wasTooBig?: boolean;
|
wasTooBig?: boolean;
|
||||||
|
|
||||||
// If `true` backfill is unavailable
|
// If `true` backfill is unavailable
|
||||||
backfillError?: boolean;
|
backfillError?: boolean;
|
||||||
|
|
||||||
totalDownloaded?: number;
|
|
||||||
incrementalMac?: string;
|
incrementalMac?: string;
|
||||||
chunkSize?: number;
|
chunkSize?: number;
|
||||||
|
|
||||||
|
@ -115,16 +143,10 @@ export type AttachmentType = {
|
||||||
// See app/attachment_channel.ts
|
// See app/attachment_channel.ts
|
||||||
version?: 1 | 2;
|
version?: 1 | 2;
|
||||||
localKey?: string; // AES + MAC
|
localKey?: string; // AES + MAC
|
||||||
thumbnailFromBackup?: Pick<
|
thumbnailFromBackup?: BackupThumbnailType;
|
||||||
AttachmentType,
|
|
||||||
'path' | 'version' | 'plaintextHash'
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Legacy field. Used only for downloading old attachments */
|
/** For quote attachments, if copied from the referenced attachment */
|
||||||
id?: number;
|
copied?: boolean;
|
||||||
|
|
||||||
/** Legacy field, used long ago for migrating attachments to disk. */
|
|
||||||
schemaVersion?: number;
|
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
isReencryptableToSameDigest?: true;
|
isReencryptableToSameDigest?: true;
|
||||||
|
@ -133,7 +155,7 @@ export type AttachmentType = {
|
||||||
isReencryptableToSameDigest: false;
|
isReencryptableToSameDigest: false;
|
||||||
reencryptionInfo?: ReencryptionInfo;
|
reencryptionInfo?: ReencryptionInfo;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type LocalAttachmentV2Type = Readonly<{
|
export type LocalAttachmentV2Type = Readonly<{
|
||||||
version: 2;
|
version: 2;
|
||||||
|
@ -259,13 +281,6 @@ export type AttachmentDraftType =
|
||||||
size: number;
|
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 {
|
export enum AttachmentVariant {
|
||||||
Default = 'Default',
|
Default = 'Default',
|
||||||
ThumbnailFromBackup = 'thumbnailFromBackup',
|
ThumbnailFromBackup = 'thumbnailFromBackup',
|
||||||
|
@ -1008,6 +1023,10 @@ export const isFile = (attachment: AttachmentType): boolean => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MIME.isLongMessage(contentType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
import type { DurationInSeconds } from '../util/durations';
|
import type { DurationInSeconds } from '../util/durations';
|
||||||
import type { AttachmentType } from './Attachment';
|
import type { AttachmentType } from './Attachment';
|
||||||
import type { EmbeddedContactType } from './EmbeddedContact';
|
import type { EmbeddedContactType } from './EmbeddedContact';
|
||||||
import type { IndexableBoolean, IndexablePresence } from './IndexedDB';
|
|
||||||
|
|
||||||
export function getMentionsRegex(): RegExp {
|
export function getMentionsRegex(): RegExp {
|
||||||
return /\uFFFC/g;
|
return /\uFFFC/g;
|
||||||
|
@ -34,7 +33,6 @@ export type IncomingMessage = Readonly<
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceDevice?: number;
|
sourceDevice?: number;
|
||||||
} & SharedMessageProperties &
|
} & SharedMessageProperties &
|
||||||
MessageSchemaVersion5 &
|
|
||||||
MessageSchemaVersion6 &
|
MessageSchemaVersion6 &
|
||||||
ExpirationTimerUpdate
|
ExpirationTimerUpdate
|
||||||
>;
|
>;
|
||||||
|
@ -56,7 +54,6 @@ export type OutgoingMessage = Readonly<
|
||||||
isViewOnce?: number;
|
isViewOnce?: number;
|
||||||
synced: boolean;
|
synced: boolean;
|
||||||
} & SharedMessageProperties &
|
} & SharedMessageProperties &
|
||||||
MessageSchemaVersion5 &
|
|
||||||
ExpirationTimerUpdate
|
ExpirationTimerUpdate
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -64,7 +61,6 @@ export type VerifiedChangeMessage = Readonly<
|
||||||
{
|
{
|
||||||
type: 'verified-change';
|
type: 'verified-change';
|
||||||
} & SharedMessageProperties &
|
} & SharedMessageProperties &
|
||||||
MessageSchemaVersion5 &
|
|
||||||
ExpirationTimerUpdate
|
ExpirationTimerUpdate
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -72,7 +68,6 @@ export type ProfileChangeNotificationMessage = Readonly<
|
||||||
{
|
{
|
||||||
type: 'profile-change';
|
type: 'profile-change';
|
||||||
} & SharedMessageProperties &
|
} & SharedMessageProperties &
|
||||||
MessageSchemaVersion5 &
|
|
||||||
ExpirationTimerUpdate
|
ExpirationTimerUpdate
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -92,14 +87,6 @@ export type ExpirationTimerUpdate = Partial<
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type MessageSchemaVersion5 = Partial<
|
|
||||||
Readonly<{
|
|
||||||
hasAttachments: IndexableBoolean;
|
|
||||||
hasVisualMediaAttachments: IndexablePresence;
|
|
||||||
hasFileAttachments: IndexablePresence;
|
|
||||||
}>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type MessageSchemaVersion6 = Partial<
|
export type MessageSchemaVersion6 = Partial<
|
||||||
Readonly<{
|
Readonly<{
|
||||||
contact: Array<EmbeddedContactType>;
|
contact: Array<EmbeddedContactType>;
|
||||||
|
|
|
@ -23,7 +23,6 @@ import {
|
||||||
} from './Attachment';
|
} from './Attachment';
|
||||||
import * as Errors from './errors';
|
import * as Errors from './errors';
|
||||||
import * as SchemaVersion from './SchemaVersion';
|
import * as SchemaVersion from './SchemaVersion';
|
||||||
import { initializeAttachmentMetadata } from './message/initializeAttachmentMetadata';
|
|
||||||
|
|
||||||
import { LONG_MESSAGE } from './MIME';
|
import { LONG_MESSAGE } from './MIME';
|
||||||
import type * as MIME from './MIME';
|
import type * as MIME from './MIME';
|
||||||
|
@ -140,6 +139,8 @@ export type ContextType = {
|
||||||
// - Attachments: write bodyAttachment to disk
|
// - Attachments: write bodyAttachment to disk
|
||||||
// Version 14
|
// Version 14
|
||||||
// - All attachments: ensure they are reencryptable to a known digest
|
// - 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;
|
const INITIAL_SCHEMA_VERSION = 0;
|
||||||
|
|
||||||
|
@ -488,12 +489,10 @@ const toVersion6 = _withSchemaVersion({
|
||||||
schemaVersion: 6,
|
schemaVersion: 6,
|
||||||
upgrade: _mapContact(Contact.parseAndWriteAvatar(migrateDataToFileSystem)),
|
upgrade: _mapContact(Contact.parseAndWriteAvatar(migrateDataToFileSystem)),
|
||||||
});
|
});
|
||||||
// IMPORTANT: We’ve updated our definition of `initializeAttachmentMetadata`, so
|
// NOOP: hasFileAttachments, etc. is now computed at message save time
|
||||||
// we need to run it again on existing items that have previously been incorrectly
|
|
||||||
// classified:
|
|
||||||
const toVersion7 = _withSchemaVersion({
|
const toVersion7 = _withSchemaVersion({
|
||||||
schemaVersion: 7,
|
schemaVersion: 7,
|
||||||
upgrade: initializeAttachmentMetadata,
|
upgrade: noopUpgrade,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toVersion8 = _withSchemaVersion({
|
const toVersion8 = _withSchemaVersion({
|
||||||
|
@ -655,6 +654,7 @@ const toVersion12 = _withSchemaVersion({
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toVersion13 = _withSchemaVersion({
|
const toVersion13 = _withSchemaVersion({
|
||||||
schemaVersion: 13,
|
schemaVersion: 13,
|
||||||
upgrade: migrateBodyAttachmentToDisk,
|
upgrade: migrateBodyAttachmentToDisk,
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { drop } from '../util/drop';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { encryptLegacyAttachment } from '../util/encryptLegacyAttachment';
|
import { encryptLegacyAttachment } from '../util/encryptLegacyAttachment';
|
||||||
import { AttachmentDisposition } from '../util/getLocalAttachmentUrl';
|
import { AttachmentDisposition } from '../util/getLocalAttachmentUrl';
|
||||||
|
import { getPlaintextHashForInMemoryAttachment } from '../AttachmentCrypto';
|
||||||
|
|
||||||
export type ActionSourceType =
|
export type ActionSourceType =
|
||||||
| 'startup'
|
| 'startup'
|
||||||
|
@ -1094,7 +1095,6 @@ export async function copyStickerToAttachments(
|
||||||
// Fall-back
|
// Fall-back
|
||||||
contentType: IMAGE_WEBP,
|
contentType: IMAGE_WEBP,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await window.Signal.Migrations.readAttachmentData(newSticker);
|
const data = await window.Signal.Migrations.readAttachmentData(newSticker);
|
||||||
|
|
||||||
const sniffedMimeType = sniffImageMimeType(data);
|
const sniffedMimeType = sniffImageMimeType(data);
|
||||||
|
@ -1106,6 +1106,8 @@ export async function copyStickerToAttachments(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newSticker.plaintextHash = getPlaintextHashForInMemoryAttachment(data);
|
||||||
|
|
||||||
return newSticker;
|
return newSticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -113,6 +113,9 @@ export type JSONWithUnknownFields<Value> =
|
||||||
export type WithRequiredProperties<T, P extends keyof T> = Omit<T, P> &
|
export type WithRequiredProperties<T, P extends keyof T> = Omit<T, P> &
|
||||||
Required<Pick<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 {
|
export function getTypingIndicatorSetting(): boolean {
|
||||||
return window.storage.get('typingIndicators', false);
|
return window.storage.get('typingIndicators', false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -4,6 +4,9 @@
|
||||||
export type NullToUndefined<T> =
|
export type NullToUndefined<T> =
|
||||||
Extract<T, null> extends never ? T : Exclude<T, null> | undefined;
|
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>(
|
export function dropNull<T>(
|
||||||
value: NonNullable<T> | null | undefined
|
value: NonNullable<T> | null | undefined
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
|
@ -35,3 +38,23 @@ export function shallowDropNull<O extends { [key: string]: any }>(
|
||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ export async function getQuoteAttachment(
|
||||||
thumbnail && thumbnail.path
|
thumbnail && thumbnail.path
|
||||||
? {
|
? {
|
||||||
...(await loadAttachmentData(thumbnail)),
|
...(await loadAttachmentData(thumbnail)),
|
||||||
objectUrl: getLocalAttachmentUrl(thumbnail),
|
url: getLocalAttachmentUrl(thumbnail),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
@ -123,7 +123,7 @@ export async function getQuoteAttachment(
|
||||||
thumbnail: path
|
thumbnail: path
|
||||||
? {
|
? {
|
||||||
...(await loadAttachmentData(sticker.data)),
|
...(await loadAttachmentData(sticker.data)),
|
||||||
objectUrl: getLocalAttachmentUrl(sticker.data),
|
url: getLocalAttachmentUrl(sticker.data),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue