Deduplicate incoming stickers from installed sticker packs

This commit is contained in:
trevor-signal
2026-02-12 12:36:53 -05:00
committed by GitHub
parent 95f131efbf
commit ae90a74cef
8 changed files with 235 additions and 194 deletions

View File

@@ -316,6 +316,7 @@ export const readAndDecryptDataFromDisk = async ({
return Buffer.concat(chunks); return Buffer.concat(chunks);
}; };
export const CURRENT_ATTACHMENT_VERSION = 2;
export const writeNewAttachmentData = async ({ export const writeNewAttachmentData = async ({
data, data,
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
@@ -333,7 +334,7 @@ export const writeNewAttachmentData = async ({
}); });
return { return {
version: 2, version: CURRENT_ATTACHMENT_VERSION,
plaintextHash, plaintextHash,
size: data.byteLength, size: data.byteLength,
path, path,

View File

@@ -21,7 +21,6 @@ import {
import { import {
maybeDeleteAttachmentFile, maybeDeleteAttachmentFile,
deleteDownloadFile, deleteDownloadFile,
doesAttachmentExist,
processNewAttachment, processNewAttachment,
} from '../util/migrations.preload.js'; } from '../util/migrations.preload.js';
import { DataReader, DataWriter } from '../sql/Client.preload.js'; import { DataReader, DataWriter } from '../sql/Client.preload.js';
@@ -62,11 +61,7 @@ import {
type JobManagerJobResultType, type JobManagerJobResultType,
type JobManagerJobType, type JobManagerJobType,
} from './JobManager.std.js'; } from './JobManager.std.js';
import { import { IMAGE_WEBP } from '../types/MIME.std.js';
IMAGE_WEBP,
type MIMEType,
stringToMIMEType,
} from '../types/MIME.std.js';
import { AttachmentDownloadSource } from '../sql/Interface.std.js'; import { AttachmentDownloadSource } from '../sql/Interface.std.js';
import { drop } from '../util/drop.std.js'; import { drop } from '../util/drop.std.js';
import { type ReencryptedAttachmentV2 } from '../AttachmentCrypto.node.js'; import { type ReencryptedAttachmentV2 } from '../AttachmentCrypto.node.js';
@@ -92,7 +87,7 @@ import { isAbortError } from '../util/isAbortError.std.js';
import { itemStorage } from '../textsecure/Storage.preload.js'; import { itemStorage } from '../textsecure/Storage.preload.js';
import { calculateExpirationTimestamp } from '../util/expirationTimer.std.js'; import { calculateExpirationTimestamp } from '../util/expirationTimer.std.js';
import { cleanupAttachmentFiles } from '../types/Message2.preload.js'; import { cleanupAttachmentFiles } from '../types/Message2.preload.js';
import type { WithRequiredProperties } from '../types/Util.std.js'; import { getExistingAttachmentDataForReuse } from '../util/attachments/deduplicateAttachment.preload.js';
const { noop, omit, throttle } = lodash; const { noop, omit, throttle } = lodash;
@@ -862,14 +857,19 @@ export async function runDownloadAttachmentJobInner({
}, },
}); });
const existingAttachmentData = await getExistingAttachmentDataForReuse({
plaintextHash: downloadedAttachment.plaintextHash,
contentType: attachment.contentType,
logId,
});
if (existingAttachmentData) {
await dependencies.maybeDeleteAttachmentFile(downloadedAttachment.path);
}
const attachmentDataToUse: Partial<AttachmentType> = { const attachmentDataToUse: Partial<AttachmentType> = {
...downloadedAttachment, ...downloadedAttachment,
...(await getExistingAttachmentDataForReuse({ ...existingAttachmentData,
downloadedAttachment,
contentType: attachment.contentType,
logId,
dependencies,
})),
}; };
const upgradedAttachment = await dependencies.processNewAttachment( const upgradedAttachment = await dependencies.processNewAttachment(
@@ -1055,100 +1055,3 @@ function _markAttachmentAsTransientlyErrored(
): AttachmentType { ): AttachmentType {
return { ...attachment, pending: false, error: true }; return { ...attachment, pending: false, error: true };
} }
type AttachmentDataToBeReused = WithRequiredProperties<
Pick<
AttachmentType,
| 'path'
| 'localKey'
| 'version'
| 'thumbnail'
| 'screenshot'
| 'width'
| 'height'
>,
'path' | 'localKey' | 'version'
>;
async function getExistingAttachmentDataForReuse({
downloadedAttachment,
contentType,
logId,
dependencies,
}: {
downloadedAttachment: ReencryptedAttachmentV2;
contentType: MIMEType;
logId: string;
dependencies: Pick<DependenciesType, 'maybeDeleteAttachmentFile'>;
}): Promise<AttachmentDataToBeReused | null> {
const existingAttachmentData =
await DataWriter.getAndProtectExistingAttachmentPath({
plaintextHash: downloadedAttachment.plaintextHash,
version: downloadedAttachment.version,
contentType,
});
if (!existingAttachmentData) {
return null;
}
strictAssert(existingAttachmentData.path, 'path must exist for reuse');
strictAssert(
existingAttachmentData.version === downloadedAttachment.version,
'version mismatch'
);
strictAssert(existingAttachmentData.localKey, 'localKey must exist');
if (!(await doesAttachmentExist(existingAttachmentData.path))) {
log.warn(
`${logId}: Existing attachment no longer exists, using newly downloaded one`
);
return null;
}
log.info(`${logId}: Reusing existing attachment`);
await dependencies.maybeDeleteAttachmentFile(downloadedAttachment.path);
const dataToReuse: AttachmentDataToBeReused = {
path: existingAttachmentData.path,
localKey: existingAttachmentData.localKey,
version: existingAttachmentData.version,
width: existingAttachmentData.width ?? undefined,
height: existingAttachmentData.height ?? undefined,
};
const { thumbnailPath, thumbnailSize, thumbnailContentType } =
existingAttachmentData;
if (
thumbnailPath &&
thumbnailSize &&
thumbnailContentType &&
(await doesAttachmentExist(thumbnailPath))
) {
dataToReuse.thumbnail = {
path: thumbnailPath,
localKey: existingAttachmentData.thumbnailLocalKey ?? undefined,
version: existingAttachmentData.thumbnailVersion ?? undefined,
size: thumbnailSize,
contentType: stringToMIMEType(thumbnailContentType),
};
}
const { screenshotPath, screenshotSize, screenshotContentType } =
existingAttachmentData;
if (
screenshotPath &&
screenshotSize &&
screenshotContentType &&
(await doesAttachmentExist(screenshotPath))
) {
dataToReuse.screenshot = {
path: screenshotPath,
localKey: existingAttachmentData.screenshotLocalKey ?? undefined,
version: existingAttachmentData.screenshotVersion ?? undefined,
size: screenshotSize,
contentType: stringToMIMEType(screenshotContentType),
};
}
return dataToReuse;
}

View File

@@ -1432,12 +1432,24 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => {
await writeAttachmentFile('existingThumbnailPath'); await writeAttachmentFile('existingThumbnailPath');
await writeAttachmentFile('existingScreenshotPath'); await writeAttachmentFile('existingScreenshotPath');
// @ts-expect-error new attachment version await DataWriter.saveMessages(
[
{
...existingMessageWithDownloadedAttachment,
attachments: [{ ...existingAttachment, version: 1 }],
},
],
{
ourAci: generateAci(),
postSaveUpdates: () => Promise.resolve(),
}
);
downloadAttachment.callsFake(async ({ attachment }) => { downloadAttachment.callsFake(async ({ attachment }) => {
return { return {
path: 'newlyDownloadedPath', path: 'newlyDownloadedPath',
plaintextHash: existingAttachment.plaintextHash, plaintextHash: existingAttachment.plaintextHash,
version: existingAttachment.version + 1, version: 2,
localKey: testAttachmentLocalKey(), localKey: testAttachmentLocalKey(),
size: existingAttachment.size, size: existingAttachment.size,
digest: attachment.digest, digest: attachment.digest,
@@ -1562,52 +1574,5 @@ describe('AttachmentDownloadManager.runDownloadAttachmentJobInner', () => {
assert.strictEqual(attachment?.path, 'existingPath'); assert.strictEqual(attachment?.path, 'existingPath');
assert.strictEqual(attachment?.thumbnail?.path, 'newThumbnailPath'); assert.strictEqual(attachment?.thumbnail?.path, 'newThumbnailPath');
}); });
it('does not reuse attachment if version is not the same as requested version', async () => {
await writeAttachmentFile('existingPath');
await writeAttachmentFile('existingThumbnailPath');
await writeAttachmentFile('existingScreenshotPath');
// @ts-expect-error version is wrong
downloadAttachment.callsFake(async ({ attachment }) => {
return {
path: 'newlyDownloadedPath',
plaintextHash: existingAttachment.plaintextHash,
version: existingAttachment.version + 1, // Newer version!
localKey: testAttachmentLocalKey(),
size: existingAttachment.size,
digest: attachment.digest,
};
});
const job = composeJob({
messageId: newMessage.id,
receivedAt: newMessage.received_at,
attachmentOverrides: undownloadedAttachment,
});
await runDownloadAttachmentJobInner({
job,
isForCurrentlyVisibleMessage: false,
hasMediaBackups: false,
abortSignal: abortController.signal,
maxAttachmentSizeInKib: 100 * MEBIBYTE,
maxTextAttachmentSizeInKib: 2 * MEBIBYTE,
messageExpiresAt: null,
dependencies: {
cleanupAttachmentFiles,
maybeDeleteAttachmentFile,
deleteDownloadFile,
downloadAttachment,
processNewAttachment,
},
});
// Cleans up newly downloaded path
assert.equal(maybeDeleteAttachmentFile.callCount, 0);
const updatedMessage = window.MessageCache.getById(newMessage.id);
const attachment = updatedMessage?.attributes.attachments?.[0];
assert.strictEqual(attachment?.path, 'newlyDownloadedPath');
});
}); });
}); });

View File

@@ -14,10 +14,15 @@ import {
getStickerPackRecordPredicate, getStickerPackRecordPredicate,
getStickerPackLink, getStickerPackLink,
} from './fixtures.node.js'; } from './fixtures.node.js';
import {
getMessageInTimelineByTimestamp,
sendTextMessage,
} from '../helpers.node.js';
import { strictAssert } from '../../util/assert.std.js';
const { StickerPackOperation } = Proto.SyncMessage; const { StickerPackOperation } = Proto.SyncMessage;
describe('storage service', function (this: Mocha.Suite) { describe('stickers', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE); this.timeout(durations.MINUTE);
let bootstrap: Bootstrap; let bootstrap: Bootstrap;
@@ -251,5 +256,56 @@ describe('storage service', function (this: Mocha.Suite) {
const finalState = await phone.expectStorageState('consistency check'); const finalState = await phone.expectStorageState('consistency check');
assert.strictEqual(finalState.version, 5); assert.strictEqual(finalState.version, 5);
debug(
'verifying that stickers from packs can be received and paths are deduplicated'
);
const firstTimestamp = bootstrap.getTimestamp();
const secondTimestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: firstContact,
to: desktop,
desktop,
text: undefined,
sticker: {
packId: STICKER_PACKS[0].id,
packKey: STICKER_PACKS[0].key,
stickerId: 0,
},
timestamp: firstTimestamp,
});
await sendTextMessage({
from: firstContact,
to: desktop,
desktop,
text: undefined,
sticker: {
packId: STICKER_PACKS[0].id,
packKey: STICKER_PACKS[0].key,
stickerId: 0,
},
timestamp: secondTimestamp,
});
await window.getByRole('button', { name: 'Go back' }).click();
await getMessageInTimelineByTimestamp(window, firstTimestamp)
.locator('.module-image--loaded')
.waitFor();
await getMessageInTimelineByTimestamp(window, secondTimestamp)
.locator('.module-image--loaded')
.waitFor();
const firstStickerData = (await app.getMessagesBySentAt(firstTimestamp))[0]
.sticker?.data;
const secondStickerData = (
await app.getMessagesBySentAt(secondTimestamp)
)[0].sticker?.data;
strictAssert(firstStickerData?.path, 'path exists');
strictAssert(firstStickerData?.localKey, 'localKey exists');
assert.strictEqual(firstStickerData.path, secondStickerData?.path);
assert.strictEqual(firstStickerData.localKey, secondStickerData?.localKey);
}); });
}); });

View File

@@ -13,7 +13,7 @@ import { getMessagesById } from '../messages/getMessagesById.preload.js';
import * as Bytes from '../Bytes.std.js'; import * as Bytes from '../Bytes.std.js';
import * as Errors from './errors.std.js'; import * as Errors from './errors.std.js';
import { deriveStickerPackKey, decryptAttachmentV1 } from '../Crypto.node.js'; import { deriveStickerPackKey, decryptAttachmentV1 } from '../Crypto.node.js';
import { IMAGE_WEBP } from './MIME.std.js'; import { IMAGE_WEBP, type MIMEType } from './MIME.std.js';
import { sniffImageMimeType } from '../util/sniffImageMimeType.std.js'; import { sniffImageMimeType } from '../util/sniffImageMimeType.std.js';
import type { import type {
AttachmentType, AttachmentType,
@@ -34,12 +34,10 @@ import {
processNewEphemeralSticker, processNewEphemeralSticker,
processNewSticker, processNewSticker,
deleteTempFile, deleteTempFile,
getAbsoluteStickerPath,
copyStickerIntoAttachmentsDirectory,
readAttachmentData,
deleteSticker, deleteSticker,
readStickerData, readStickerData,
writeNewStickerData, writeNewStickerData,
writeNewAttachmentData,
} from '../util/migrations.preload.js'; } from '../util/migrations.preload.js';
import { drop } from '../util/drop.std.js'; import { drop } from '../util/drop.std.js';
import { isNotNil } from '../util/isNotNil.std.js'; import { isNotNil } from '../util/isNotNil.std.js';
@@ -52,6 +50,7 @@ import {
getSticker as doGetSticker, getSticker as doGetSticker,
getStickerPackManifest, getStickerPackManifest,
} from '../textsecure/WebAPI.preload.js'; } from '../textsecure/WebAPI.preload.js';
import { getExistingAttachmentDataForReuse } from '../util/attachments/deduplicateAttachment.preload.js';
const { isNumber, reject, groupBy, values, chunk } = lodash; const { isNumber, reject, groupBy, values, chunk } = lodash;
@@ -1106,33 +1105,46 @@ export async function copyStickerToAttachments(
); );
} }
const { path: stickerPath } = sticker; const data = await readStickerData(sticker);
const absolutePath = getAbsoluteStickerPath(stickerPath); const size = data.byteLength;
const { path, size } = const plaintextHash = getPlaintextHashForInMemoryAttachment(data);
await copyStickerIntoAttachmentsDirectory(absolutePath);
const newSticker: AttachmentType = {
...sticker,
path,
size,
// Fall-back
contentType: IMAGE_WEBP,
};
const data = await readAttachmentData(newSticker);
let contentType: MIMEType;
const sniffedMimeType = sniffImageMimeType(data); const sniffedMimeType = sniffImageMimeType(data);
if (sniffedMimeType) { if (sniffedMimeType) {
newSticker.contentType = sniffedMimeType; contentType = sniffedMimeType;
} else { } else {
log.warn( log.warn(
'copyStickerToAttachments: Unable to sniff sticker MIME type; falling back to WebP' 'copyStickerToAttachments: Unable to sniff sticker MIME type; falling back to WebP'
); );
contentType = IMAGE_WEBP;
} }
newSticker.plaintextHash = getPlaintextHashForInMemoryAttachment(data); const newSticker: AttachmentType = {
width: sticker.width,
height: sticker.height,
version: sticker.version,
size,
contentType,
plaintextHash,
};
return newSticker; const existingAttachmentData = await getExistingAttachmentDataForReuse({
plaintextHash,
contentType,
logId: 'copyStickerToAttachments',
});
if (existingAttachmentData) {
return {
...newSticker,
...existingAttachmentData,
};
}
const newAttachmentData = await writeNewAttachmentData(data);
return { ...newSticker, ...newAttachmentData };
} }
// In the case where a sticker pack is uninstalled, we want to delete it if there are no // In the case where a sticker pack is uninstalled, we want to delete it if there are no

View File

@@ -0,0 +1,108 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createLogger } from '../../logging/log.std.js';
import { DataWriter } from '../../sql/Client.preload.js';
import { doesAttachmentExist } from '../migrations.preload.js';
import { type MIMEType, stringToMIMEType } from '../../types/MIME.std.js';
import { strictAssert } from '../assert.std.js';
import { type WithRequiredProperties } from '../../types/Util.std.js';
import { type AttachmentType } from '../../types/Attachment.std.js';
import { CURRENT_ATTACHMENT_VERSION } from '../../../app/attachments.node.js';
const log = createLogger('deduplicateAttachment');
type AttachmentDataToBeReused = WithRequiredProperties<
Pick<
AttachmentType,
| 'path'
| 'localKey'
| 'version'
| 'thumbnail'
| 'screenshot'
| 'width'
| 'height'
>,
'path' | 'localKey' | 'version'
>;
export async function getExistingAttachmentDataForReuse({
plaintextHash,
contentType,
logId,
}: {
plaintextHash: string;
contentType: MIMEType;
logId?: string;
}): Promise<AttachmentDataToBeReused | null> {
const existingAttachmentData =
await DataWriter.getAndProtectExistingAttachmentPath({
plaintextHash,
version: CURRENT_ATTACHMENT_VERSION,
contentType,
});
if (!existingAttachmentData) {
return null;
}
strictAssert(existingAttachmentData.path, 'path must exist for reuse');
strictAssert(
existingAttachmentData.version === CURRENT_ATTACHMENT_VERSION,
'version mismatch'
);
strictAssert(existingAttachmentData.localKey, 'localKey must exist');
if (!(await doesAttachmentExist(existingAttachmentData.path))) {
log.warn(
`${logId}: Existing attachment no longer exists, using newly downloaded one`
);
return null;
}
log.info(`${logId}: Reusing existing attachment`);
const dataToReuse: AttachmentDataToBeReused = {
path: existingAttachmentData.path,
localKey: existingAttachmentData.localKey,
version: existingAttachmentData.version,
width: existingAttachmentData.width ?? undefined,
height: existingAttachmentData.height ?? undefined,
};
const { thumbnailPath, thumbnailSize, thumbnailContentType } =
existingAttachmentData;
if (
thumbnailPath &&
thumbnailSize &&
thumbnailContentType &&
(await doesAttachmentExist(thumbnailPath))
) {
dataToReuse.thumbnail = {
path: thumbnailPath,
localKey: existingAttachmentData.thumbnailLocalKey ?? undefined,
version: existingAttachmentData.thumbnailVersion ?? undefined,
size: thumbnailSize,
contentType: stringToMIMEType(thumbnailContentType),
};
}
const { screenshotPath, screenshotSize, screenshotContentType } =
existingAttachmentData;
if (
screenshotPath &&
screenshotSize &&
screenshotContentType &&
(await doesAttachmentExist(screenshotPath))
) {
dataToReuse.screenshot = {
path: screenshotPath,
localKey: existingAttachmentData.screenshotLocalKey ?? undefined,
version: existingAttachmentData.screenshotVersion ?? undefined,
size: screenshotSize,
contentType: stringToMIMEType(screenshotContentType),
};
}
return dataToReuse;
}

View File

@@ -137,12 +137,6 @@ export const getAbsoluteStickerPath = createAbsolutePathGetter(STICKERS_PATH);
export const writeNewStickerData = createEncryptedWriterForNew(STICKERS_PATH); export const writeNewStickerData = createEncryptedWriterForNew(STICKERS_PATH);
export const deleteSticker = createDeleter(STICKERS_PATH); export const deleteSticker = createDeleter(STICKERS_PATH);
export const readStickerData = createEncryptedReader(STICKERS_PATH); export const readStickerData = createEncryptedReader(STICKERS_PATH);
export const copyStickerIntoAttachmentsDirectory = copyIntoAttachmentsDirectory(
{
sourceDir: STICKERS_PATH,
targetDir: ATTACHMENTS_PATH,
}
);
export const getAbsoluteBadgeImageFilePath = export const getAbsoluteBadgeImageFilePath =
createAbsolutePathGetter(BADGES_PATH); createAbsolutePathGetter(BADGES_PATH);

View File

@@ -342,9 +342,8 @@ export async function queueAttachmentDownloads(
try { try {
log.info(`${logId}: Copying sticker from installed pack`); log.info(`${logId}: Copying sticker from installed pack`);
copiedSticker = true; copiedSticker = true;
count += 1;
const data = await copyStickerToAttachments(packId, stickerId);
const data = await copyStickerToAttachments(packId, stickerId);
// Refresh sticker attachment since we had to await above // Refresh sticker attachment since we had to await above
const freshSticker = message.get('sticker'); const freshSticker = message.get('sticker');
strictAssert(freshSticker != null, 'Sticker is gone while copying'); strictAssert(freshSticker != null, 'Sticker is gone while copying');
@@ -460,13 +459,16 @@ export async function queueAttachmentDownloads(
message.set({ editHistory }); message.set({ editHistory });
} }
if (count <= 0) { if (count > 0) {
return false; log.info(`${logId}: Queued ${count} total attachment downloads`);
return true;
} }
log.info(`${logId}: Queued ${count} total attachment downloads`); if (copiedSticker) {
return true;
}
return true; return false;
} }
export async function queueNormalAttachments({ export async function queueNormalAttachments({