Improve export handling of body ranges and storyReactions

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-03-10 15:41:49 -05:00
committed by GitHub
parent d8c6008fa1
commit f782f00580
3 changed files with 122 additions and 19 deletions

View File

@@ -32,7 +32,10 @@ import {
type AciString,
type ServiceIdString,
} from '../../types/ServiceId.std.js';
import type { RawBodyRange } from '../../types/BodyRange.std.js';
import {
bodyRangeSchema,
type RawBodyRange,
} from '../../types/BodyRange.std.js';
import { PaymentEventKind } from '../../types/Payment.std.js';
import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent.std.js';
import type {
@@ -183,6 +186,7 @@ import { ChatFolderType } from '../../types/ChatFolder.std.js';
import { expiresTooSoonForBackup } from './util/expiration.std.js';
import type { PinnedMessage } from '../../types/PinnedMessage.std.js';
import type { ThemeType } from '../../util/preload.preload.js';
import { safeParseStrict } from '../../util/schemas.std.js';
const { isNumber } = lodash;
@@ -1675,7 +1679,7 @@ export class BackupExportStream extends Readable {
state,
};
}
} else if (message.storyReplyContext) {
} else if (message.storyReplyContext || message.storyReaction) {
result.directStoryReplyMessage = await this.#toDirectStoryReplyMessage({
message,
});
@@ -2787,9 +2791,7 @@ export class BackupExportStream extends Readable {
quote.text != null
? {
body: trimBody(quote.text, BACKUP_QUOTE_BODY_LIMIT),
bodyRanges: quote.bodyRanges?.map(range =>
this.#toBodyRange(range)
),
bodyRanges: this.#toBodyRanges(quote.bodyRanges),
}
: null,
attachments: await Promise.all(
@@ -2814,22 +2816,45 @@ export class BackupExportStream extends Readable {
};
}
#toBodyRange(range: RawBodyRange): Backups.IBodyRange {
return {
start: range.start,
length: range.length,
#toBodyRange(range: RawBodyRange): Backups.IBodyRange | null {
const { data: parsedRange, error } = safeParseStrict(
bodyRangeSchema,
range
);
...('mentionAci' in range
if (error) {
log.warn('toBodyRange: Dropping invalid body range', toLogFormat(error));
return null;
}
return {
start: parsedRange.start,
length: parsedRange.length,
...('mentionAci' in parsedRange
? {
mentionAci: this.#aciToBytes(range.mentionAci),
mentionAci: this.#aciToBytes(parsedRange.mentionAci),
}
: {
// Numeric values are compatible between backup and message protos
style: range.style,
style: parsedRange.style,
}),
};
}
#toBodyRanges(
ranges: ReadonlyArray<RawBodyRange> | undefined
): Array<Backups.IBodyRange> | undefined {
if (!ranges?.length) {
return undefined;
}
const result = ranges
.map(range => this.#toBodyRange(range))
.filter(isNotNil);
return result.length > 0 ? result : undefined;
}
#getMessageAttachmentFlag(
message: Pick<MessageAttributesType, 'body'>,
attachment: AttachmentType
@@ -3194,8 +3219,8 @@ export class BackupExportStream extends Readable {
}> {
const includeLongTextAttachment =
message.bodyAttachment && !isDownloaded(message.bodyAttachment);
const includeText =
Boolean(message.body) || Boolean(message.bodyRanges?.length);
const bodyRanges = this.#toBodyRanges(message.bodyRanges);
const includeText = Boolean(message.body) || Boolean(bodyRanges?.length);
return {
longText:
@@ -3217,9 +3242,7 @@ export class BackupExportStream extends Readable {
: MAX_BACKUP_MESSAGE_BODY_BYTE_LENGTH
)
: undefined,
bodyRanges: message.bodyRanges?.map(range =>
this.#toBodyRange(range)
),
bodyRanges,
}
: undefined,
};

View File

@@ -28,6 +28,7 @@ import { IMAGE_PNG, TEXT_ATTACHMENT } from '../../types/MIME.std.js';
import { MY_STORY_ID } from '../../types/Stories.std.js';
import { generateAttachmentKeys } from '../../AttachmentCrypto.node.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { BodyRange } from '../../types/BodyRange.std.js';
const CONTACT_A = generateAci();
const CONTACT_B = generateAci();
@@ -607,7 +608,7 @@ describe('backup/bubble messages', () => {
await asymmetricRoundtripHarness(
[
{
conversationId: gv1.id,
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
@@ -625,6 +626,62 @@ describe('backup/bubble messages', () => {
[]
);
});
it('drops invalid body ranges', async () => {
await asymmetricRoundtripHarness(
[
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
sourceServiceId: CONTACT_A,
body: 'd',
bodyRanges: [
{
start: 0,
length: 1,
// @ts-expect-error invalid data
style: undefined,
},
{
start: 1,
length: 0,
style: BodyRange.Style.BOLD,
},
],
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
unidentifiedDeliveryReceived: true,
},
],
[
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
sourceServiceId: CONTACT_A,
body: 'd',
bodyRanges: [
{
start: 1,
length: 0,
style: BodyRange.Style.BOLD,
},
],
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
unidentifiedDeliveryReceived: true,
},
]
);
});
it('drops messages that expire soon', async () => {
await asymmetricRoundtripHarness(

View File

@@ -4,6 +4,7 @@
/* eslint-disable @typescript-eslint/no-namespace */
import lodash from 'lodash';
import * as z from 'zod';
import { SignalService as Proto } from '../protobuf/index.std.js';
import { createLogger } from '../logging/log.std.js';
@@ -15,7 +16,8 @@ import {
SNIPPET_TRUNCATION_PLACEHOLDER,
} from '../util/search.std.js';
import { assertDev } from '../util/assert.std.js';
import type { AciString } from './ServiceId.std.js';
import { aciSchema, type AciString } from './ServiceId.std.js';
import { signalservice } from '../protobuf/compiled.std.js';
const { isEqual, isNumber, omit, orderBy, partition } = lodash;
@@ -869,3 +871,24 @@ export function areBodyRangesEqual(
return isEqual(normalizedLeft, sortedRight);
}
const bodyRangeOffsetSchema = z.number().int().min(0);
const bodyRangeStyleSchema = z.nativeEnum(signalservice.BodyRange.Style);
export const bodyRangeSchema = z.union([
z
.object({
start: bodyRangeOffsetSchema,
length: bodyRangeOffsetSchema,
mentionAci: aciSchema,
})
.strict(),
z
.object({
start: bodyRangeOffsetSchema,
length: bodyRangeOffsetSchema,
style: bodyRangeStyleSchema,
spoilerId: z.number().optional(),
})
.strict(),
]);