Inline long-text messages in the backup proto
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
parent
7eb30447d8
commit
52c57e3000
4 changed files with 144 additions and 25 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -234,7 +234,7 @@ jobs:
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
with:
|
with:
|
||||||
repository: 'signalapp/Signal-Message-Backup-Tests'
|
repository: 'signalapp/Signal-Message-Backup-Tests'
|
||||||
ref: '316c3d5eb15ffc923b5ce2925094fd49b34e8203'
|
ref: 'df09e4bfa985c68daf845ad96abae3ae8f9b07ca'
|
||||||
path: 'backup-integration-tests'
|
path: 'backup-integration-tests'
|
||||||
|
|
||||||
- run: xvfb-run --auto-servernum pnpm run test-electron
|
- run: xvfb-run --auto-servernum pnpm run test-electron
|
||||||
|
|
|
@ -144,7 +144,7 @@ import {
|
||||||
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
|
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
|
||||||
import { SeenStatus } from '../../MessageSeenStatus';
|
import { SeenStatus } from '../../MessageSeenStatus';
|
||||||
import { migrateAllMessages } from '../../messages/migrateMessageData';
|
import { migrateAllMessages } from '../../messages/migrateMessageData';
|
||||||
import { trimBody } from '../../util/longAttachment';
|
import { isBodyTooLong, trimBody } from '../../util/longAttachment';
|
||||||
import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData';
|
import { generateBackupsSubscriberData } from '../../util/backupSubscriptionData';
|
||||||
import {
|
import {
|
||||||
getEnvironment,
|
getEnvironment,
|
||||||
|
@ -157,6 +157,7 @@ import { isValidE164 } from '../../util/isValidE164';
|
||||||
import { toDayOfWeekArray } from '../../types/NotificationProfile';
|
import { toDayOfWeekArray } from '../../types/NotificationProfile';
|
||||||
import { getLinkPreviewSetting } from '../../types/LinkPreview';
|
import { getLinkPreviewSetting } from '../../types/LinkPreview';
|
||||||
import { getTypingIndicatorSetting } from '../../types/Util';
|
import { getTypingIndicatorSetting } from '../../types/Util';
|
||||||
|
import { KIBIBYTE } from '../../types/AttachmentSize';
|
||||||
|
|
||||||
const log = createLogger('export');
|
const log = createLogger('export');
|
||||||
|
|
||||||
|
@ -170,6 +171,8 @@ const FLUSH_TIMEOUT = 30 * MINUTE;
|
||||||
// Threshold for reporting slow flushes
|
// Threshold for reporting slow flushes
|
||||||
const REPORTING_THRESHOLD = SECOND;
|
const REPORTING_THRESHOLD = SECOND;
|
||||||
|
|
||||||
|
const BACKUP_LONG_ATTACHMENT_TEXT_LIMIT = 128 * KIBIBYTE;
|
||||||
|
|
||||||
type GetRecipientIdOptionsType =
|
type GetRecipientIdOptionsType =
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
serviceId: ServiceIdString;
|
serviceId: ServiceIdString;
|
||||||
|
@ -2799,10 +2802,18 @@ export class BackupExportStream extends Readable {
|
||||||
| 'preview'
|
| 'preview'
|
||||||
| 'reactions'
|
| 'reactions'
|
||||||
| 'received_at'
|
| 'received_at'
|
||||||
|
| 'timestamp'
|
||||||
>;
|
>;
|
||||||
backupLevel: BackupLevel;
|
backupLevel: BackupLevel;
|
||||||
isLocalBackup: boolean;
|
isLocalBackup: boolean;
|
||||||
}): Promise<Backups.IStandardMessage> {
|
}): Promise<Backups.IStandardMessage> {
|
||||||
|
if (
|
||||||
|
message.body &&
|
||||||
|
isBodyTooLong(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT)
|
||||||
|
) {
|
||||||
|
log.warn(`${message.timestamp}: Message body is too long; will truncate`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quote: await this.#toQuote({
|
quote: await this.#toQuote({
|
||||||
message,
|
message,
|
||||||
|
@ -2821,7 +2832,10 @@ export class BackupExportStream extends Readable {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
longText: message.bodyAttachment
|
longText:
|
||||||
|
// We only include the bodyAttachment if it's not downloaded; otherwise all text
|
||||||
|
// is inlined
|
||||||
|
message.bodyAttachment && !isDownloaded(message.bodyAttachment)
|
||||||
? await this.#processAttachment({
|
? await this.#processAttachment({
|
||||||
attachment: message.bodyAttachment,
|
attachment: message.bodyAttachment,
|
||||||
backupLevel,
|
backupLevel,
|
||||||
|
@ -2832,7 +2846,9 @@ export class BackupExportStream extends Readable {
|
||||||
text:
|
text:
|
||||||
message.body != null
|
message.body != null
|
||||||
? {
|
? {
|
||||||
body: message.body ? trimBody(message.body) : undefined,
|
body: message.body
|
||||||
|
? trimBody(message.body, BACKUP_LONG_ATTACHMENT_TEXT_LIMIT)
|
||||||
|
: undefined,
|
||||||
bodyRanges: message.bodyRanges?.map(range =>
|
bodyRanges: message.bodyRanges?.map(range =>
|
||||||
this.#toBodyRange(range)
|
this.#toBodyRange(range)
|
||||||
),
|
),
|
||||||
|
|
|
@ -41,7 +41,7 @@ import {
|
||||||
generateKeys,
|
generateKeys,
|
||||||
getPlaintextHashForInMemoryAttachment,
|
getPlaintextHashForInMemoryAttachment,
|
||||||
} from '../../AttachmentCrypto';
|
} from '../../AttachmentCrypto';
|
||||||
import { isValidAttachmentKey } from '../../types/Crypto';
|
import { KIBIBYTE } from '../../types/AttachmentSize';
|
||||||
|
|
||||||
const CONTACT_A = generateAci();
|
const CONTACT_A = generateAci();
|
||||||
|
|
||||||
|
@ -149,6 +149,7 @@ describe('backup/attachments', () => {
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('long-message attachments', () => {
|
describe('long-message attachments', () => {
|
||||||
it('preserves attachment still on message.attachments', async () => {
|
it('preserves attachment still on message.attachments', async () => {
|
||||||
const longMessageAttachment = composeAttachment(1, {
|
const longMessageAttachment = composeAttachment(1, {
|
||||||
|
@ -188,7 +189,7 @@ describe('backup/attachments', () => {
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
body: body.slice(0, 2048),
|
body,
|
||||||
bodyAttachment: {
|
bodyAttachment: {
|
||||||
contentType: LONG_MESSAGE,
|
contentType: LONG_MESSAGE,
|
||||||
size: bodyBytes.byteLength,
|
size: bodyBytes.byteLength,
|
||||||
|
@ -206,22 +207,23 @@ describe('backup/attachments', () => {
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
expected.bodyAttachment,
|
expected.bodyAttachment,
|
||||||
// all encryption info will be generated anew
|
omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
|
||||||
omit(msgInDB.bodyAttachment, ['digest', 'key', 'downloadPath'])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.isUndefined(msgInDB.bodyAttachment?.digest);
|
assert.isUndefined(msgInDB.bodyAttachment?.digest);
|
||||||
assert.isTrue(isValidAttachmentKey(msgInDB.bodyAttachment?.key));
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles existing bodyAttachments', async () => {
|
it('handles existing bodyAttachments', async () => {
|
||||||
|
const body = 'a'.repeat(3000);
|
||||||
|
const bodyBytes = Bytes.fromString(body);
|
||||||
|
|
||||||
const attachment = omit(
|
const attachment = omit(
|
||||||
composeAttachment(1, {
|
composeAttachment(1, {
|
||||||
contentType: LONG_MESSAGE,
|
contentType: LONG_MESSAGE,
|
||||||
size: 3000,
|
size: bodyBytes.byteLength,
|
||||||
downloadPath: 'downloadPath',
|
downloadPath: 'downloadPath',
|
||||||
}),
|
}),
|
||||||
'thumbnail'
|
'thumbnail'
|
||||||
|
@ -232,13 +234,17 @@ describe('backup/attachments', () => {
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
bodyAttachment: attachment,
|
bodyAttachment: attachment,
|
||||||
body: 'a'.repeat(3000),
|
body,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
body: 'a'.repeat(2048),
|
body,
|
||||||
bodyAttachment: expectedRoundtrippedFields(attachment),
|
bodyAttachment: {
|
||||||
|
contentType: LONG_MESSAGE,
|
||||||
|
size: 3000,
|
||||||
|
plaintextHash: getPlaintextHashForInMemoryAttachment(bodyBytes),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
@ -250,11 +256,105 @@ describe('backup/attachments', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
omit(expected.bodyAttachment, ['clientUuid', 'downloadPath']),
|
expected.bodyAttachment,
|
||||||
omit(msgInDB.bodyAttachment, ['clientUuid', 'downloadPath'])
|
omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('truncates at 128 KiB', async () => {
|
||||||
|
const body = 'a'.repeat(129 * KIBIBYTE);
|
||||||
|
const truncatedBody = body.slice(0, 128 * KIBIBYTE);
|
||||||
|
const bodyBytes = Bytes.fromString(body);
|
||||||
|
|
||||||
|
const attachment = omit(
|
||||||
|
composeAttachment(1, {
|
||||||
|
contentType: LONG_MESSAGE,
|
||||||
|
size: bodyBytes.byteLength,
|
||||||
|
downloadPath: 'downloadPath',
|
||||||
|
}),
|
||||||
|
'thumbnail'
|
||||||
|
);
|
||||||
|
strictAssert(attachment.digest, 'must exist');
|
||||||
|
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
bodyAttachment: attachment,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
body: truncatedBody,
|
||||||
|
bodyAttachment: {
|
||||||
|
contentType: LONG_MESSAGE,
|
||||||
|
size: 128 * KIBIBYTE,
|
||||||
|
plaintextHash: getPlaintextHashForInMemoryAttachment(
|
||||||
|
Bytes.fromString(truncatedBody)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Paid,
|
||||||
|
comparator: (expected, msgInDB) => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
omit(expected, 'bodyAttachment'),
|
||||||
|
omit(msgInDB, 'bodyAttachment')
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.isNotEmpty(msgInDB.bodyAttachment?.downloadPath);
|
assert.deepStrictEqual(
|
||||||
|
expected.bodyAttachment,
|
||||||
|
omit(msgInDB.bodyAttachment, ['localKey', 'path', 'version'])
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('includes bodyAttachment if it has not downloaded', async () => {
|
||||||
|
const truncatedBody = 'a'.repeat(2 * KIBIBYTE);
|
||||||
|
|
||||||
|
const attachment = omit(
|
||||||
|
composeAttachment(1, {
|
||||||
|
contentType: LONG_MESSAGE,
|
||||||
|
size: 64 * KIBIBYTE,
|
||||||
|
path: undefined,
|
||||||
|
plaintextHash: undefined,
|
||||||
|
localKey: undefined,
|
||||||
|
downloadPath: undefined,
|
||||||
|
clientUuid: undefined, // clientUuids are not roundtripped for bodyAttachments
|
||||||
|
}),
|
||||||
|
'thumbnail'
|
||||||
|
);
|
||||||
|
strictAssert(attachment.digest, 'must exist');
|
||||||
|
|
||||||
|
await asymmetricRoundtripHarness(
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
body: truncatedBody,
|
||||||
|
bodyAttachment: attachment,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
composeMessage(1, {
|
||||||
|
body: truncatedBody,
|
||||||
|
bodyAttachment: attachment,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
backupLevel: BackupLevel.Paid,
|
||||||
|
comparator: (expected, msgInDB) => {
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
omit(msgInDB, 'bodyAttachment'),
|
||||||
|
omit(expected, 'bodyAttachment')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
omit(msgInDB.bodyAttachment, ['downloadPath']),
|
||||||
|
expected.bodyAttachment
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,8 +5,11 @@ import { unicodeSlice } from './unicodeSlice';
|
||||||
|
|
||||||
const LONG_ATTACHMENT_LIMIT = 2048;
|
const LONG_ATTACHMENT_LIMIT = 2048;
|
||||||
|
|
||||||
export function isBodyTooLong(body: string): boolean {
|
export function isBodyTooLong(
|
||||||
return Buffer.byteLength(body) > LONG_ATTACHMENT_LIMIT;
|
body: string,
|
||||||
|
length = LONG_ATTACHMENT_LIMIT
|
||||||
|
): boolean {
|
||||||
|
return Buffer.byteLength(body) > length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trimBody(body: string, length = LONG_ATTACHMENT_LIMIT): string {
|
export function trimBody(body: string, length = LONG_ATTACHMENT_LIMIT): string {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue