mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { assert } from 'chai';
|
|
import fsExtra from 'fs-extra';
|
|
import { v4 as generateUuid } from 'uuid';
|
|
import { readdirSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
|
|
import { DataWriter } from '../sql/Client.preload.js';
|
|
import { missingCaseError } from '../util/missingCaseError.std.js';
|
|
import {
|
|
getAbsoluteAttachmentPath,
|
|
getAbsoluteDownloadsPath,
|
|
getAbsoluteDraftPath,
|
|
getAbsoluteMegaphoneImageFilePath,
|
|
} from '../util/migrations.preload.js';
|
|
import {
|
|
getDownloadsPath,
|
|
getDraftPath,
|
|
getAttachmentsPath,
|
|
} from '../windows/main/attachments.preload.js';
|
|
|
|
import { generateAci } from '../types/ServiceId.std.js';
|
|
import { IMAGE_JPEG, LONG_MESSAGE } from '../types/MIME.std.js';
|
|
import type { MessageAttributesType } from '../model-types.d.ts';
|
|
import type { RemoteMegaphoneId } from '../types/Megaphone.std.js';
|
|
|
|
const { emptyDir, ensureFile } = fsExtra;
|
|
|
|
type TestAttachmentTypes = 'attachment' | 'download' | 'draft' | 'megaphone';
|
|
|
|
function getAbsolutePath(path: string, type: TestAttachmentTypes) {
|
|
switch (type) {
|
|
case 'attachment':
|
|
return getAbsoluteAttachmentPath(path);
|
|
case 'download':
|
|
return getAbsoluteDownloadsPath(path);
|
|
case 'draft':
|
|
return getAbsoluteDraftPath(path);
|
|
case 'megaphone':
|
|
return getAbsoluteMegaphoneImageFilePath(path);
|
|
default:
|
|
throw missingCaseError(type);
|
|
}
|
|
}
|
|
|
|
async function writeFile(path: string, type: TestAttachmentTypes) {
|
|
await ensureFile(getAbsolutePath(path, type));
|
|
}
|
|
|
|
async function writeFiles(num: number, type: TestAttachmentTypes) {
|
|
for (let i = 0; i < num; i += 1) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await writeFile(`${type}${i}`, type);
|
|
}
|
|
}
|
|
|
|
function listFiles(type: TestAttachmentTypes): Array<string> {
|
|
return readdirSync(dirname(getAbsolutePath('fakename', type)));
|
|
}
|
|
|
|
let attachmentIndex = 0;
|
|
let downloadIndex = 0;
|
|
|
|
describe('cleanupOrphanedAttachments', () => {
|
|
// TODO (DESKTOP-8613): stickers & badges
|
|
beforeEach(async () => {
|
|
await DataWriter.removeAll();
|
|
|
|
attachmentIndex = 0;
|
|
downloadIndex = 0;
|
|
await emptyDir(
|
|
getAttachmentsPath(window.SignalContext.config.userDataPath)
|
|
);
|
|
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
|
|
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await emptyDir(
|
|
getAttachmentsPath(window.SignalContext.config.userDataPath)
|
|
);
|
|
await emptyDir(getDownloadsPath(window.SignalContext.config.userDataPath));
|
|
await emptyDir(getDraftPath(window.SignalContext.config.userDataPath));
|
|
});
|
|
|
|
function getAttachmentFilePath() {
|
|
attachmentIndex += 1;
|
|
return `attachment${attachmentIndex}`;
|
|
}
|
|
function getDownloadFilePath() {
|
|
downloadIndex += 1;
|
|
return `download${downloadIndex}`;
|
|
}
|
|
|
|
function composeAttachment() {
|
|
return {
|
|
contentType: IMAGE_JPEG,
|
|
size: 128,
|
|
path: getAttachmentFilePath(),
|
|
downloadPath: getDownloadFilePath(),
|
|
thumbnail: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 128,
|
|
path: getAttachmentFilePath(),
|
|
},
|
|
screenshot: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 128,
|
|
path: getAttachmentFilePath(),
|
|
},
|
|
thumbnailFromBackup: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 128,
|
|
path: getAttachmentFilePath(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function composeBodyAttachment() {
|
|
return {
|
|
contentType: LONG_MESSAGE,
|
|
size: 128,
|
|
path: getAttachmentFilePath(),
|
|
downloadPath: getDownloadFilePath(),
|
|
};
|
|
}
|
|
|
|
it('deletes paths if not referenced', async () => {
|
|
await writeFiles(2, 'attachment');
|
|
await writeFiles(2, 'draft');
|
|
await writeFiles(2, 'download');
|
|
|
|
assert.sameDeepMembers(listFiles('attachment'), [
|
|
'attachment0',
|
|
'attachment1',
|
|
]);
|
|
assert.sameDeepMembers(listFiles('draft'), ['draft0', 'draft1']);
|
|
assert.sameDeepMembers(listFiles('download'), ['download0', 'download1']);
|
|
|
|
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: 'attachment0',
|
|
},
|
|
profileAvatar: {
|
|
path: 'attachment1',
|
|
},
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
assert.sameDeepMembers(listFiles('attachment'), [
|
|
'attachment0',
|
|
'attachment1',
|
|
]);
|
|
});
|
|
|
|
describe('message attachments', () => {
|
|
// Update these if more paths are added to composeMessageWithAllAttachments
|
|
const NUM_ATTACHMENT_FILES_IN_MESSAGE = 26;
|
|
const NUM_DOWNLOAD_FILES_IN_MESSAGE = 8;
|
|
function composeMessageWithAllAttachments(): MessageAttributesType {
|
|
return {
|
|
id: generateUuid(),
|
|
type: 'outgoing',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: generateUuid(),
|
|
bodyAttachment: composeBodyAttachment(),
|
|
attachments: [composeAttachment(), composeAttachment()],
|
|
contact: [
|
|
{
|
|
avatar: {
|
|
isProfile: false,
|
|
avatar: composeAttachment(),
|
|
},
|
|
},
|
|
],
|
|
preview: [
|
|
{
|
|
url: 'url',
|
|
image: composeAttachment(),
|
|
},
|
|
],
|
|
editHistory: [
|
|
{
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
bodyAttachment: composeBodyAttachment(),
|
|
},
|
|
],
|
|
quote: {
|
|
id: Date.now(),
|
|
isViewOnce: false,
|
|
referencedMessageNotFound: false,
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: composeAttachment(),
|
|
},
|
|
],
|
|
},
|
|
sticker: {
|
|
packId: 'packId',
|
|
stickerId: 42,
|
|
packKey: 'packKey',
|
|
data: composeAttachment(),
|
|
},
|
|
};
|
|
}
|
|
|
|
it('does not delete message attachments (including thumbnails, previews, avatars, etc.)', async () => {
|
|
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
|
|
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
|
|
|
|
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
|
|
ourAci: generateAci(),
|
|
forceSave: true,
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
|
|
assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);
|
|
|
|
const attachmentFiles = listFiles('attachment');
|
|
const downloadFiles = listFiles('download');
|
|
|
|
assert.strictEqual(
|
|
attachmentFiles.length,
|
|
NUM_ATTACHMENT_FILES_IN_MESSAGE
|
|
);
|
|
assert.sameDeepMembers(
|
|
attachmentFiles,
|
|
new Array(attachmentIndex)
|
|
.fill(null)
|
|
.map((_, idx) => `attachment${idx + 1}`)
|
|
);
|
|
|
|
assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE);
|
|
assert.sameDeepMembers(
|
|
downloadFiles,
|
|
new Array(downloadIndex)
|
|
.fill(null)
|
|
.map((_, idx) => `download${idx + 1}`)
|
|
);
|
|
});
|
|
|
|
it('does not delete any protected attachment paths', async () => {
|
|
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
|
|
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
|
|
|
|
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
|
|
ourAci: generateAci(),
|
|
forceSave: true,
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
await DataWriter._protectAttachmentPathFromDeletion(
|
|
`attachment${attachmentIndex + 1}`
|
|
);
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
|
|
|
|
const attachmentFiles = listFiles('attachment');
|
|
|
|
assert.strictEqual(
|
|
attachmentFiles.length,
|
|
NUM_ATTACHMENT_FILES_IN_MESSAGE + 1
|
|
);
|
|
assert.sameDeepMembers(
|
|
attachmentFiles,
|
|
new Array(attachmentIndex + 1)
|
|
.fill(null)
|
|
.map((_, idx) => `attachment${idx + 1}`)
|
|
);
|
|
});
|
|
|
|
it('works with non-normalized message attachments', async () => {
|
|
await writeFiles(NUM_ATTACHMENT_FILES_IN_MESSAGE + 5, 'attachment');
|
|
await writeFiles(NUM_DOWNLOAD_FILES_IN_MESSAGE + 5, 'download');
|
|
|
|
await DataWriter.saveMessage(composeMessageWithAllAttachments(), {
|
|
ourAci: generateAci(),
|
|
forceSave: true,
|
|
// Save one with attachments not normalized
|
|
_testOnlyAvoidNormalizingAttachments: true,
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
assert.strictEqual(attachmentIndex, NUM_ATTACHMENT_FILES_IN_MESSAGE);
|
|
assert.strictEqual(downloadIndex, NUM_DOWNLOAD_FILES_IN_MESSAGE);
|
|
|
|
const attachmentFiles = listFiles('attachment');
|
|
const downloadFiles = listFiles('download');
|
|
|
|
assert.strictEqual(
|
|
attachmentFiles.length,
|
|
NUM_ATTACHMENT_FILES_IN_MESSAGE
|
|
);
|
|
assert.sameDeepMembers(
|
|
attachmentFiles,
|
|
new Array(attachmentIndex)
|
|
.fill(null)
|
|
.map((_, idx) => `attachment${idx + 1}`)
|
|
);
|
|
|
|
assert.strictEqual(downloadFiles.length, NUM_DOWNLOAD_FILES_IN_MESSAGE);
|
|
assert.sameDeepMembers(
|
|
downloadFiles,
|
|
new Array(downloadIndex)
|
|
.fill(null)
|
|
.map((_, idx) => `download${idx + 1}`)
|
|
);
|
|
});
|
|
|
|
it('will NOT delete copied quote attachments if there is at least one strong reference', async () => {
|
|
await writeFiles(10, 'attachment');
|
|
|
|
const quotedMessage = {
|
|
id: generateUuid(),
|
|
type: 'outgoing',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: generateUuid(),
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
size: 128,
|
|
path: 'attachment1',
|
|
thumbnail: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 42,
|
|
// strong reference
|
|
path: 'attachment2',
|
|
},
|
|
},
|
|
],
|
|
} as const;
|
|
|
|
const quotingMessage = {
|
|
id: generateUuid(),
|
|
type: 'outgoing',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: generateUuid(),
|
|
quote: {
|
|
id: quotedMessage.sent_at,
|
|
isViewOnce: false,
|
|
referencedMessageNotFound: false,
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 42,
|
|
// weak (copied) reference
|
|
path: 'attachment2',
|
|
copied: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as const;
|
|
|
|
// Make sure we constructed the test correctly: both attachments reference the same
|
|
// path on disk
|
|
assert.strictEqual(
|
|
quotedMessage.attachments[0].thumbnail.path,
|
|
quotingMessage.quote.attachments[0].thumbnail.path
|
|
);
|
|
|
|
await DataWriter.saveMessages([quotedMessage, quotingMessage], {
|
|
ourAci: generateAci(),
|
|
forceSave: true,
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
const attachmentFilesLeftOnDisk = listFiles('attachment');
|
|
|
|
assert.strictEqual(attachmentFilesLeftOnDisk.length, 2);
|
|
|
|
assert.sameDeepMembers(attachmentFilesLeftOnDisk, [
|
|
'attachment1',
|
|
'attachment2',
|
|
]);
|
|
});
|
|
|
|
it('will delete quote attachments if there are only weak references', async () => {
|
|
await writeFiles(10, 'attachment');
|
|
|
|
const quotingMessage = {
|
|
id: generateUuid(),
|
|
type: 'outgoing',
|
|
sent_at: Date.now(),
|
|
timestamp: Date.now(),
|
|
received_at: Date.now(),
|
|
conversationId: generateUuid(),
|
|
quote: {
|
|
id: Date.now(),
|
|
isViewOnce: false,
|
|
referencedMessageNotFound: false,
|
|
attachments: [
|
|
{
|
|
contentType: IMAGE_JPEG,
|
|
thumbnail: {
|
|
contentType: IMAGE_JPEG,
|
|
size: 42,
|
|
path: 'attachment1',
|
|
copied: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as const;
|
|
|
|
await DataWriter.saveMessage(quotingMessage, {
|
|
ourAci: generateAci(),
|
|
forceSave: true,
|
|
postSaveUpdates: () => Promise.resolve(),
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
const attachmentFilesLeftOnDisk = listFiles('attachment');
|
|
|
|
assert.strictEqual(attachmentFilesLeftOnDisk.length, 0);
|
|
});
|
|
});
|
|
|
|
it('does not delete megaphone image paths', async () => {
|
|
// Write 2 files so 1 is orphaned
|
|
await writeFiles(2, 'megaphone');
|
|
|
|
await DataWriter.createMegaphone({
|
|
id: generateUuid() as RemoteMegaphoneId,
|
|
desktopMinVersion: '1.0.0',
|
|
priority: 1,
|
|
dontShowBeforeEpochMs: 0,
|
|
dontShowAfterEpochMs: Date.now() + 9001,
|
|
showForNumberOfDays: 7,
|
|
primaryCtaId: null,
|
|
secondaryCtaId: null,
|
|
primaryCtaData: null,
|
|
secondaryCtaData: null,
|
|
conditionalId: null,
|
|
title: 'a',
|
|
body: 'b',
|
|
primaryCtaText: null,
|
|
secondaryCtaText: null,
|
|
imagePath: 'megaphone0',
|
|
localeFetched: 'en',
|
|
shownAt: null,
|
|
snoozedAt: null,
|
|
snoozeCount: 0,
|
|
isFinished: false,
|
|
});
|
|
|
|
await DataWriter.cleanupOrphanedAttachments({ _block: true });
|
|
|
|
// Only the file associated with the megaphone is retained
|
|
assert.sameDeepMembers(listFiles('megaphone'), ['megaphone0']);
|
|
});
|
|
});
|