Additional work to include story=true on send

This commit is contained in:
Scott Nonnenberg 2022-10-07 10:02:08 -07:00 committed by GitHub
parent 3bfeffe502
commit 4ec48df5b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 327 additions and 170 deletions

View file

@ -196,7 +196,7 @@
"@babel/preset-typescript": "7.17.12", "@babel/preset-typescript": "7.17.12",
"@electron/fuses": "1.5.0", "@electron/fuses": "1.5.0",
"@mixer/parallel-prettier": "2.0.1", "@mixer/parallel-prettier": "2.0.1",
"@signalapp/mock-server": "2.10.0", "@signalapp/mock-server": "2.11.0",
"@storybook/addon-a11y": "6.5.6", "@storybook/addon-a11y": "6.5.6",
"@storybook/addon-actions": "6.5.6", "@storybook/addon-actions": "6.5.6",
"@storybook/addon-controls": "6.5.6", "@storybook/addon-controls": "6.5.6",

View file

@ -35,7 +35,8 @@ message Envelope {
optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline
optional bool urgent = 14 [default=true]; // indicates that the content is considered timely by the sender; defaults to true so senders have to opt-out to say something isn't time critical optional bool urgent = 14 [default=true]; // indicates that the content is considered timely by the sender; defaults to true so senders have to opt-out to say something isn't time critical
optional string updated_pni = 15; optional string updated_pni = 15;
// next: 16 optional bool story = 16; // indicates that the content is a story.
// next: 17
} }
message Content { message Content {

View file

@ -131,32 +131,33 @@ message AccountRecord {
} }
} }
optional bytes profileKey = 1; optional bytes profileKey = 1;
optional string givenName = 2; optional string givenName = 2;
optional string familyName = 3; optional string familyName = 3;
optional string avatarUrl = 4; optional string avatarUrl = 4;
optional bool noteToSelfArchived = 5; optional bool noteToSelfArchived = 5;
optional bool readReceipts = 6; optional bool readReceipts = 6;
optional bool sealedSenderIndicators = 7; optional bool sealedSenderIndicators = 7;
optional bool typingIndicators = 8; optional bool typingIndicators = 8;
optional bool proxiedLinkPreviews = 9; optional bool proxiedLinkPreviews = 9;
optional bool noteToSelfMarkedUnread = 10; optional bool noteToSelfMarkedUnread = 10;
optional bool linkPreviews = 11; optional bool linkPreviews = 11;
optional PhoneNumberSharingMode phoneNumberSharingMode = 12; optional PhoneNumberSharingMode phoneNumberSharingMode = 12;
optional bool notDiscoverableByPhoneNumber = 13; optional bool notDiscoverableByPhoneNumber = 13;
repeated PinnedConversation pinnedConversations = 14; repeated PinnedConversation pinnedConversations = 14;
optional bool preferContactAvatars = 15; optional bool preferContactAvatars = 15;
optional uint32 universalExpireTimer = 17; optional uint32 universalExpireTimer = 17;
optional bool primarySendsSms = 18; optional bool primarySendsSms = 18;
optional string e164 = 19; optional string e164 = 19;
repeated string preferredReactionEmoji = 20; repeated string preferredReactionEmoji = 20;
optional bytes subscriberId = 21; optional bytes subscriberId = 21;
optional string subscriberCurrencyCode = 22; optional string subscriberCurrencyCode = 22;
optional bool displayBadgesOnProfile = 23; optional bool displayBadgesOnProfile = 23;
optional bool keepMutedChatsArchived = 25; optional bool keepMutedChatsArchived = 25;
optional bool hasSetMyStoriesPrivacy = 26; optional bool hasSetMyStoriesPrivacy = 26;
reserved /* hasViewedOnboardingStory */ 27; reserved /* hasViewedOnboardingStory */ 27;
optional bool storiesDisabled = 28; reserved 28; // deprecatedStoriesDisabled
optional bool storiesDisabled = 29;
} }
message StoryDistributionListRecord { message StoryDistributionListRecord {

View file

@ -183,6 +183,7 @@ export async function sendNormalMessage(
quote, quote,
recipients: allRecipientIdentifiers, recipients: allRecipientIdentifiers,
sticker, sticker,
// No storyContext; you can't reply to your own stories
timestamp: messageTimestamp, timestamp: messageTimestamp,
}); });
messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors); messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors);
@ -234,6 +235,7 @@ export async function sendNormalMessage(
sendOptions, sendOptions,
sendTarget: conversation.toSenderKeyTarget(), sendTarget: conversation.toSenderKeyTarget(),
sendType: 'message', sendType: 'message',
story: Boolean(storyContext),
urgent: true, urgent: true,
}) })
); );
@ -282,6 +284,7 @@ export async function sendNormalMessage(
sticker, sticker,
storyContext, storyContext,
timestamp: messageTimestamp, timestamp: messageTimestamp,
// Note: 1:1 story replies should not set story=true - they aren't group sends
urgent: true, urgent: true,
includePniSignatureMessage: true, includePniSignatureMessage: true,
}); });

View file

@ -264,7 +264,8 @@ export async function sendStory(
const recipientsSet = new Set(pendingSendRecipientIds); const recipientsSet = new Set(pendingSendRecipientIds);
const sendOptions = await getSendOptionsForRecipients( const sendOptions = await getSendOptionsForRecipients(
pendingSendRecipientIds pendingSendRecipientIds,
{ story: true }
); );
log.info( log.info(

View file

@ -179,11 +179,13 @@ export class Reactions extends Collection<ReactionModel> {
storyReactionEmoji: reaction.get('emoji'), storyReactionEmoji: reaction.get('emoji'),
}); });
const [generatedMessageId] = await Promise.all([ // Note: generatedMessage comes with an id, so we have to force this save
await Promise.all([
window.Signal.Data.saveMessage(generatedMessage.attributes, { window.Signal.Data.saveMessage(generatedMessage.attributes, {
ourUuid: window.textsecure.storage.user ourUuid: window.textsecure.storage.user
.getCheckedUuid() .getCheckedUuid()
.toString(), .toString(),
forceSave: true,
}), }),
generatedMessage.hydrateStoryContext(message), generatedMessage.hydrateStoryContext(message),
]); ]);
@ -197,10 +199,8 @@ export class Reactions extends Collection<ReactionModel> {
timestamp: reaction.get('timestamp'), timestamp: reaction.get('timestamp'),
}); });
generatedMessage.set({ id: generatedMessageId });
const messageToAdd = window.MessageController.register( const messageToAdd = window.MessageController.register(
generatedMessageId, generatedMessage.id,
generatedMessage generatedMessage
); );
targetConversation.addSingleMessage(messageToAdd); targetConversation.addSingleMessage(messageToAdd);

View file

@ -159,7 +159,6 @@ import { getMessageIdForLogging } from '../util/idForLogging';
import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { findStoryMessage } from '../util/findStoryMessage'; import { findStoryMessage } from '../util/findStoryMessage';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoryDataFromMessageAttributes } from '../services/storyLoader'; import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
@ -2097,20 +2096,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
await conversation.queueJob('handleDataMessage', async () => { await conversation.queueJob('handleDataMessage', async () => {
log.info(`${idLog}: starting processing in queue`); log.info(`${idLog}: starting processing in queue`);
if (
isStory(message.attributes) &&
!isConversationAccepted(conversation.attributes, {
ignoreEmptyConvo: true,
})
) {
log.info(
`${idLog}: dropping story from !accepted`,
this.getSenderIdentifier()
);
confirm();
return;
}
// First, check for duplicates. If we find one, stop processing here. // First, check for duplicates. If we find one, stop processing here.
const inMemoryMessage = window.MessageController.findBySender( const inMemoryMessage = window.MessageController.findBySender(
this.getSenderIdentifier() this.getSenderIdentifier()
@ -2387,8 +2372,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const messageId = message.get('id') || UUID.generate().toString(); const messageId = message.get('id') || UUID.generate().toString();
// Send delivery receipts, but only for incoming sealed sender messages // Send delivery receipts, but only for non-story sealed sender messages
// and not for messages from unaccepted conversations // and not for messages from unaccepted conversations
if ( if (
type === 'incoming' && type === 'incoming' &&
this.get('unidentifiedDeliveryReceived') && this.get('unidentifiedDeliveryReceived') &&

View file

@ -265,6 +265,7 @@ export type UnprocessedType = {
serverTimestamp?: number; serverTimestamp?: number;
decrypted?: string; decrypted?: string;
urgent?: boolean; urgent?: boolean;
story?: boolean;
}; };
export type UnprocessedUpdateType = { export type UnprocessedUpdateType = {

View file

@ -3178,6 +3178,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
serverTimestamp, serverTimestamp,
decrypted, decrypted,
urgent, urgent,
story,
} = data; } = data;
if (!id) { if (!id) {
throw new Error('saveUnprocessedSync: id was falsey'); throw new Error('saveUnprocessedSync: id was falsey');
@ -3204,7 +3205,8 @@ function saveUnprocessedSync(data: UnprocessedType): string {
serverGuid, serverGuid,
serverTimestamp, serverTimestamp,
decrypted, decrypted,
urgent urgent,
story
) values ( ) values (
$id, $id,
$timestamp, $timestamp,
@ -3218,7 +3220,8 @@ function saveUnprocessedSync(data: UnprocessedType): string {
$serverGuid, $serverGuid,
$serverTimestamp, $serverTimestamp,
$decrypted, $decrypted,
$urgent $urgent,
$story
); );
` `
).run({ ).run({
@ -3235,6 +3238,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
serverTimestamp: serverTimestamp || null, serverTimestamp: serverTimestamp || null,
decrypted: decrypted || null, decrypted: decrypted || null,
urgent: urgent || !isBoolean(urgent) ? 1 : 0, urgent: urgent || !isBoolean(urgent) ? 1 : 0,
story: story ? 1 : 0,
}); });
return id; return id;
@ -3309,6 +3313,7 @@ async function getUnprocessedById(
return { return {
...row, ...row,
urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true,
story: Boolean(row.story),
}; };
} }
@ -3370,6 +3375,7 @@ async function getAllUnprocessedAndIncrementAttempts(): Promise<
.map(row => ({ .map(row => ({
...row, ...row,
urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true, urgent: isNumber(row.urgent) ? Boolean(row.urgent) : true,
story: Boolean(row.story),
})); }));
})(); })();
} }

View file

@ -0,0 +1,28 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export default function updateToSchemaVersion67(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 67) {
return;
}
db.transaction(() => {
db.exec(
`
ALTER TABLE unprocessed ADD COLUMN story INTEGER;
`
);
db.pragma('user_version = 67');
})();
logger.info('updateToSchemaVersion67: success!');
}

View file

@ -42,6 +42,7 @@ import updateToSchemaVersion63 from './63-add-urgent-to-unprocessed';
import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys'; import updateToSchemaVersion64 from './64-uuid-column-for-pre-keys';
import updateToSchemaVersion65 from './65-add-storage-id-to-stickers'; import updateToSchemaVersion65 from './65-add-storage-id-to-stickers';
import updateToSchemaVersion66 from './66-add-pni-signature-to-sent-protos'; import updateToSchemaVersion66 from './66-add-pni-signature-to-sent-protos';
import updateToSchemaVersion67 from './67-add-story-to-unprocessed';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1947,6 +1948,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion64, updateToSchemaVersion64,
updateToSchemaVersion65, updateToSchemaVersion65,
updateToSchemaVersion66, updateToSchemaVersion66,
updateToSchemaVersion67,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -15,7 +15,7 @@ export const debug = createDebug('mock:test:rate-limit');
const IdentifierType = Proto.ManifestRecord.Identifier.Type; const IdentifierType = Proto.ManifestRecord.Identifier.Type;
describe('rate-limit/story', function needsName() { describe('story/no-sender-key', function needsName() {
this.timeout(durations.MINUTE); this.timeout(durations.MINUTE);
let bootstrap: Bootstrap; let bootstrap: Bootstrap;
@ -65,7 +65,7 @@ describe('rate-limit/story', function needsName() {
await bootstrap.teardown(); await bootstrap.teardown();
}); });
it('should request challenge and accept solution', async () => { it('should successfully send story', async () => {
const { const {
server, server,
contactsWithoutProfileKey: contacts, contactsWithoutProfileKey: contacts,
@ -115,29 +115,6 @@ describe('rate-limit/story', function needsName() {
await window.locator('button.SendStoryModal__send').click(); await window.locator('button.SendStoryModal__send').click();
} }
debug('Waiting for challenge');
const request = await app.waitForChallenge();
debug('Checking for presence of captcha modal');
await window
.locator('.module-Modal__title >> "Verify to continue messaging"')
.waitFor();
debug('Removing rate-limiting');
for (const contact of contacts) {
const failedMessages = server.stopRateLimiting({
source: desktop.uuid,
target: contact.device.uuid,
});
assert.isAtMost(failedMessages ?? 0, 1);
}
debug('Solving challenge');
app.solveChallenge({
seq: request.seq,
data: { captcha: 'anything' },
});
debug('Verifying that all contacts received story'); debug('Verifying that all contacts received story');
await Promise.all( await Promise.all(
contacts.map(async contact => { contacts.map(async contact => {

View file

@ -52,7 +52,6 @@ import { QualifiedAddress } from '../types/QualifiedAddress';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import { UUID, UUIDKind } from '../types/UUID'; import { UUID, UUIDKind } from '../types/UUID';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { isEnabled } from '../RemoteConfig';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
@ -115,6 +114,8 @@ import { areArraysMatchingSets } from '../util/areArraysMatchingSets';
import { generateBlurHash } from '../util/generateBlurHash'; import { generateBlurHash } from '../util/generateBlurHash';
import { TEXT_ATTACHMENT } from '../types/MIME'; import { TEXT_ATTACHMENT } from '../types/MIME';
import type { SendTypesType } from '../util/handleMessageSend'; import type { SendTypesType } from '../util/handleMessageSend';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { getStoriesBlocked } from '../types/Stories';
const GROUPV1_ID_LENGTH = 16; const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32; const GROUPV2_ID_LENGTH = 32;
@ -394,6 +395,7 @@ export default class MessageReceiver
serverGuid: decoded.serverGuid, serverGuid: decoded.serverGuid,
serverTimestamp, serverTimestamp,
urgent: isBoolean(decoded.urgent) ? decoded.urgent : true, urgent: isBoolean(decoded.urgent) ? decoded.urgent : true,
story: decoded.story,
}; };
// After this point, decoding errors are not the server's // After this point, decoding errors are not the server's
@ -777,6 +779,7 @@ export default class MessageReceiver
serverTimestamp: serverTimestamp:
item.serverTimestamp || decoded.serverTimestamp?.toNumber(), item.serverTimestamp || decoded.serverTimestamp?.toNumber(),
urgent: isBoolean(item.urgent) ? item.urgent : true, urgent: isBoolean(item.urgent) ? item.urgent : true,
story: Boolean(item.story),
}; };
const { decrypted } = item; const { decrypted } = item;
@ -1043,6 +1046,7 @@ export default class MessageReceiver
receivedAtCounter: envelope.receivedAtCounter, receivedAtCounter: envelope.receivedAtCounter,
timestamp: envelope.timestamp, timestamp: envelope.timestamp,
urgent: envelope.urgent, urgent: envelope.urgent,
story: envelope.story,
}; };
this.decryptAndCacheBatcher.add({ this.decryptAndCacheBatcher.add({
request, request,
@ -1271,10 +1275,10 @@ export default class MessageReceiver
envelope: UnsealedEnvelope, envelope: UnsealedEnvelope,
uuidKind: UUIDKind uuidKind: UUIDKind
): Promise<DecryptResult> { ): Promise<DecryptResult> {
const logId = getEnvelopeId(envelope); const logId = `MessageReceiver.decryptEnvelope(${getEnvelopeId(envelope)})`;
if (this.stoppingProcessing) { if (this.stoppingProcessing) {
log.warn(`MessageReceiver.decryptEnvelope(${logId}): dropping unsealed`); log.warn(`${logId}: dropping unsealed`);
throw new Error('Unsealed envelope dropped due to stopping processing'); throw new Error('Unsealed envelope dropped due to stopping processing');
} }
@ -1298,7 +1302,7 @@ export default class MessageReceiver
); );
} }
log.info(`MessageReceiver.decryptEnvelope(${logId})`); log.info(logId);
const plaintext = await this.decrypt( const plaintext = await this.decrypt(
stores, stores,
envelope, envelope,
@ -1307,7 +1311,7 @@ export default class MessageReceiver
); );
if (!plaintext) { if (!plaintext) {
log.warn('MessageReceiver.decryptEnvelope: plaintext was falsey'); log.warn(`${logId}: plaintext was falsey`);
return { plaintext, envelope }; return { plaintext, envelope };
} }
@ -1331,6 +1335,53 @@ export default class MessageReceiver
envelope, envelope,
content.senderKeyDistributionMessage content.senderKeyDistributionMessage
); );
} else {
// Note: `story = true` can be set for sender key distribution messages
const isStoryReply = Boolean(content.dataMessage?.storyContext);
const isGroupStoryReply = Boolean(
isStoryReply && content.dataMessage?.groupV2
);
const isStory = Boolean(content.storyMessage);
const isGroupStorySend = isGroupStoryReply || isStory;
const isDeleteForEveryone = Boolean(content.dataMessage?.delete);
if (envelope.story && !isGroupStorySend && !isDeleteForEveryone) {
log.warn(
`${logId}: Dropping story message - story=true on envelope, but message was not a group story send or delete`
);
this.removeFromCache(envelope);
return { plaintext: undefined, envelope };
}
if (!envelope.story && isGroupStorySend) {
log.warn(
`${logId}: Malformed story - story=false on envelope, but was a group story send`
);
}
const areStoriesBlocked = getStoriesBlocked();
// Note that there are other story-related message types which aren't captured
// here. Look for other calls to getStoriesBlocked down-file.
if (areStoriesBlocked && (isStoryReply || isStory)) {
log.warn(
`${logId}: Dropping story message - stories are disabled or unavailable`
);
this.removeFromCache(envelope);
return { plaintext: undefined, envelope };
}
const sender = window.ConversationController.get(
envelope.sourceUuid || envelope.source
);
if (
(!sender || !isConversationAccepted(sender.attributes)) &&
(isStoryReply || isStory)
) {
log.warn(`${logId}: Dropping story message - !accepted for sender`);
this.removeFromCache(envelope);
return { plaintext: undefined, envelope };
}
} }
if (content.pniSignatureMessage) { if (content.pniSignatureMessage) {
@ -1359,8 +1410,7 @@ export default class MessageReceiver
inProgressMessageType = ''; inProgressMessageType = '';
} catch (error) { } catch (error) {
log.error( log.error(
'MessageReceiver.decryptEnvelope: ' + `${logId}: Failed to process ${inProgressMessageType} ` +
`Failed to process ${inProgressMessageType} ` +
`message: ${Errors.toLogFormat(error)}` `message: ${Errors.toLogFormat(error)}`
); );
} }
@ -1371,9 +1421,7 @@ export default class MessageReceiver
((envelope.source && this.isBlocked(envelope.source)) || ((envelope.source && this.isBlocked(envelope.source)) ||
(envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid))) (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)))
) { ) {
log.info( log.info(`${logId}: Dropping non-GV2 message from blocked sender`);
'MessageReceiver.decryptEnvelope: Dropping non-GV2 message from blocked sender'
);
return { plaintext: undefined, envelope }; return { plaintext: undefined, envelope };
} }
@ -1900,16 +1948,19 @@ export default class MessageReceiver
sentMessage?: ProcessedSent sentMessage?: ProcessedSent
): Promise<void> { ): Promise<void> {
const logId = getEnvelopeId(envelope); const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleStoryMessage', logId);
const attachments: Array<ProcessedAttachment> = []; logUnexpectedUrgentValue(envelope, 'story');
if (window.Events.getHasStoriesDisabled()) { if (getStoriesBlocked()) {
log.info('MessageReceiver.handleStoryMessage: dropping', logId); log.info('MessageReceiver.handleStoryMessage: dropping', logId);
this.removeFromCache(envelope); this.removeFromCache(envelope);
return; return;
} }
log.info('MessageReceiver.handleStoryMessage', logId);
const attachments: Array<ProcessedAttachment> = [];
if (msg.fileAttachment) { if (msg.fileAttachment) {
const attachment = processAttachment(msg.fileAttachment); const attachment = processAttachment(msg.fileAttachment);
attachments.push(attachment); attachments.push(attachment);
@ -2076,16 +2127,12 @@ export default class MessageReceiver
const logId = getEnvelopeId(envelope); const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleDataMessage', logId); log.info('MessageReceiver.handleDataMessage', logId);
const isStoriesEnabled = if (getStoriesBlocked() && msg.storyContext) {
isEnabled('desktop.stories') || isEnabled('desktop.internalUser');
if (!isStoriesEnabled && msg.storyContext) {
logUnexpectedUrgentValue(envelope, 'story');
log.info( log.info(
`MessageReceiver.handleDataMessage/${logId}: Dropping incoming dataMessage with storyContext field` `MessageReceiver.handleDataMessage/${logId}: Dropping incoming dataMessage with storyContext field`
); );
this.removeFromCache(envelope); this.removeFromCache(envelope);
return undefined; return;
} }
let p: Promise<void> = Promise.resolve(); let p: Promise<void> = Promise.resolve();
@ -2129,9 +2176,7 @@ export default class MessageReceiver
let type: SendTypesType = 'message'; let type: SendTypesType = 'message';
if (msg.storyContext) { if (msg.storyContext || msg.body) {
type = 'story';
} else if (msg.body) {
type = 'message'; type = 'message';
} else if (msg.reaction) { } else if (msg.reaction) {
type = 'reaction'; type = 'reaction';
@ -2294,19 +2339,8 @@ export default class MessageReceiver
return; return;
} }
const isStoriesEnabled =
isEnabled('desktop.stories') || isEnabled('desktop.internalUser');
if (content.storyMessage) { if (content.storyMessage) {
if (isStoriesEnabled) { await this.handleStoryMessage(envelope, content.storyMessage);
await this.handleStoryMessage(envelope, content.storyMessage);
return;
}
const logId = getEnvelopeId(envelope);
log.info(
`innerHandleContentMessage/${logId}: Dropping incoming message with storyMessage field`
);
this.removeFromCache(envelope);
return; return;
} }
@ -2689,16 +2723,17 @@ export default class MessageReceiver
const sentMessage = syncMessage.sent; const sentMessage = syncMessage.sent;
if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) { if (sentMessage.storyMessageRecipients && sentMessage.isRecipientUpdate) {
if (window.Events.getHasStoriesDisabled()) { if (getStoriesBlocked()) {
log.info( log.info(
'MessageReceiver.handleSyncMessage: dropping story recipients update' 'MessageReceiver.handleSyncMessage: dropping story recipients update',
getEnvelopeId(envelope)
); );
this.removeFromCache(envelope); this.removeFromCache(envelope);
return; return;
} }
log.info( log.info(
'MessageReceiver.handleSyncMessage: handling storyMessageRecipients isRecipientUpdate sync message', 'MessageReceiver.handleSyncMessage: handling story recipients update',
getEnvelopeId(envelope) getEnvelopeId(envelope)
); );
const ev = new StoryRecipientUpdateEvent( const ev = new StoryRecipientUpdateEvent(

View file

@ -1255,6 +1255,7 @@ export default class MessageSender {
groupId, groupId,
options, options,
urgent, urgent,
story,
}: Readonly<{ }: Readonly<{
timestamp: number; timestamp: number;
recipients: Array<string>; recipients: Array<string>;
@ -1263,6 +1264,7 @@ export default class MessageSender {
groupId: string | undefined; groupId: string | undefined;
options?: SendOptionsType; options?: SendOptionsType;
urgent: boolean; urgent: boolean;
story?: boolean;
}>): Promise<CallbackResultType> { }>): Promise<CallbackResultType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const callback = (result: CallbackResultType) => { const callback = (result: CallbackResultType) => {
@ -1282,6 +1284,7 @@ export default class MessageSender {
recipients, recipients,
timestamp, timestamp,
urgent, urgent,
story,
}); });
}); });
} }

View file

@ -94,6 +94,7 @@ export type ProcessedEnvelope = Readonly<{
serverTimestamp: number; serverTimestamp: number;
groupId?: string; groupId?: string;
urgent?: boolean; urgent?: boolean;
story?: boolean;
}>; }>;
export type ProcessedAttachment = { export type ProcessedAttachment = {

View file

@ -942,6 +942,7 @@ export type WebAPIType = {
timestamp: number, timestamp: number,
options: { options: {
online?: boolean; online?: boolean;
story?: boolean;
urgent?: boolean; urgent?: boolean;
} }
) => Promise<MultiRecipient200ResponseType>; ) => Promise<MultiRecipient200ResponseType>;
@ -2122,14 +2123,13 @@ export function initialize({
messages, messages,
timestamp, timestamp,
online: Boolean(online), online: Boolean(online),
story,
urgent, urgent,
}; };
await _ajax({ await _ajax({
call: 'messages', call: 'messages',
httpType: 'PUT', httpType: 'PUT',
urlParameters: `/${destination}`, urlParameters: `/${destination}?story=${booleanToString(story)}`,
jsonData, jsonData,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
@ -2151,14 +2151,13 @@ export function initialize({
messages, messages,
timestamp, timestamp,
online: Boolean(online), online: Boolean(online),
story,
urgent, urgent,
}; };
await _ajax({ await _ajax({
call: 'messages', call: 'messages',
httpType: 'PUT', httpType: 'PUT',
urlParameters: `/${destination}`, urlParameters: `/${destination}?story=${booleanToString(story)}`,
jsonData, jsonData,
responseType: 'json', responseType: 'json',
}); });
@ -2175,20 +2174,23 @@ export function initialize({
{ {
online, online,
urgent = true, urgent = true,
story = false,
}: { }: {
online?: boolean; online?: boolean;
story?: boolean;
urgent?: boolean; urgent?: boolean;
} }
): Promise<MultiRecipient200ResponseType> { ): Promise<MultiRecipient200ResponseType> {
const onlineParam = `&online=${booleanToString(online)}`; const onlineParam = `&online=${booleanToString(online)}`;
const urgentParam = `&urgent=${booleanToString(urgent)}`; const urgentParam = `&urgent=${booleanToString(urgent)}`;
const storyParam = `&story=${booleanToString(story)}`;
const response = await _ajax({ const response = await _ajax({
call: 'multiRecipient', call: 'multiRecipient',
httpType: 'PUT', httpType: 'PUT',
contentType: 'application/vnd.signal-messenger.mrm', contentType: 'application/vnd.signal-messenger.mrm',
data, data,
urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}`, urlParameters: `?ts=${timestamp}${onlineParam}${urgentParam}${storyParam}`,
responseType: 'json', responseType: 'json',
unauthenticated: true, unauthenticated: true,
accessKey: Bytes.toBase64(accessKeys), accessKey: Bytes.toBase64(accessKeys),

View file

@ -9,6 +9,7 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStatus } from '../messages/MessageSendState'; import type { SendStatus } from '../messages/MessageSendState';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists'; import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
import { isEnabled } from '../RemoteConfig';
export type ReplyType = { export type ReplyType = {
author: Pick< author: Pick<
@ -142,3 +143,9 @@ export enum HasStories {
Read = 'Read', Read = 'Read',
Unread = 'Unread', Unread = 'Unread',
} }
const getStoriesAvailable = () =>
isEnabled('desktop.stories') || isEnabled('desktop.internalUser');
const getStoriesDisabled = () => window.Events.getHasStoriesDisabled();
export const getStoriesBlocked = (): boolean =>
!getStoriesAvailable() || getStoriesDisabled();

View file

@ -7,7 +7,7 @@ import type {
SendOptionsType, SendOptionsType,
} from '../textsecure/SendMessage'; } from '../textsecure/SendMessage';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { getRandomBytes } from '../Crypto'; import { getRandomBytes, getZeroes } from '../Crypto';
import { getConversationMembers } from './getConversationMembers'; import { getConversationMembers } from './getConversationMembers';
import { isDirectConversation, isMe } from './whatTypeOfConversation'; import { isDirectConversation, isMe } from './whatTypeOfConversation';
import { senderCertificateService } from '../services/senderCertificate'; import { senderCertificateService } from '../services/senderCertificate';
@ -24,14 +24,17 @@ const SEALED_SENDER = {
}; };
export async function getSendOptionsForRecipients( export async function getSendOptionsForRecipients(
recipients: ReadonlyArray<string> recipients: ReadonlyArray<string>,
options?: { story?: boolean }
): Promise<SendOptionsType> { ): Promise<SendOptionsType> {
const conversations = recipients const conversations = recipients
.map(identifier => window.ConversationController.get(identifier)) .map(identifier => window.ConversationController.get(identifier))
.filter(isNotNil); .filter(isNotNil);
const metadataList = await Promise.all( const metadataList = await Promise.all(
conversations.map(conversation => getSendOptions(conversation.attributes)) conversations.map(conversation =>
getSendOptions(conversation.attributes, options)
)
); );
return metadataList.reduce( return metadataList.reduce(
@ -58,9 +61,9 @@ export async function getSendOptionsForRecipients(
export async function getSendOptions( export async function getSendOptions(
conversationAttrs: ConversationAttributesType, conversationAttrs: ConversationAttributesType,
options: { syncMessage?: boolean } = {} options: { syncMessage?: boolean; story?: boolean } = {}
): Promise<SendOptionsType> { ): Promise<SendOptionsType> {
const { syncMessage } = options; const { syncMessage, story } = options;
if (!isDirectConversation(conversationAttrs)) { if (!isDirectConversation(conversationAttrs)) {
const contactCollection = getConversationMembers(conversationAttrs); const contactCollection = getConversationMembers(conversationAttrs);
@ -97,9 +100,13 @@ export async function getSendOptions(
); );
// If we've never fetched user's profile, we default to what we have // If we've never fetched user's profile, we default to what we have
if (sealedSender === SEALED_SENDER.UNKNOWN) { if (sealedSender === SEALED_SENDER.UNKNOWN || story) {
const identifierData = { const identifierData = {
accessKey: accessKey || Bytes.toBase64(getRandomBytes(16)), accessKey:
accessKey ||
(story
? Bytes.toBase64(getZeroes(16))
: Bytes.toBase64(getRandomBytes(16))),
senderCertificate, senderCertificate,
}; };
return { return {

View file

@ -38,6 +38,7 @@ import type {
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import MessageSender from '../textsecure/SendMessage'; import MessageSender from '../textsecure/SendMessage';
import type { StoryDistributionListDataType } from '../state/ducks/storyDistributionLists';
const RETRY_LIMIT = 5; const RETRY_LIMIT = 5;
@ -138,7 +139,8 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise<void> {
const { contentHint, messageIds, proto, timestamp, urgent } = sentProto; const { contentHint, messageIds, proto, timestamp, urgent } = sentProto;
const { contentProto, groupId } = await maybeAddSenderKeyDistributionMessage({ // Only applies to sender key sends in groups. See below for story distribution lists.
const addSenderKeyResult = await maybeAddSenderKeyDistributionMessage({
contentProto: Proto.Content.decode(proto), contentProto: Proto.Content.decode(proto),
logId, logId,
messageIds, messageIds,
@ -146,44 +148,35 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise<void> {
requesterUuid, requesterUuid,
timestamp, timestamp,
}); });
// eslint-disable-next-line prefer-destructuring
let contentProto: Proto.IContent | undefined =
addSenderKeyResult.contentProto;
const { groupId } = addSenderKeyResult;
// Assert that the requesting UUID is still part of a distribution list that // Assert that the requesting UUID is still part of a story distribution list that
// the message was sent to. // the message was sent to, and add its sender key distribution message (SKDM).
if (contentProto.storyMessage) { if (contentProto.storyMessage && !groupId) {
const { storyDistributionLists } = window.reduxStore.getState(); contentProto = await checkDistributionListAndAddSKDM({
const membersByListId = new Map<string, Set<string>>(); confirm,
storyDistributionLists.distributionLists.forEach(list => { contentProto,
membersByListId.set(list.id, new Set(list.memberUuids)); logId,
messaging,
requesterUuid,
timestamp,
}); });
if (!contentProto) {
const messages = await dataInterface.getMessagesBySentAt(timestamp);
const isInDistributionList = messages.some(message => {
if (!message.storyDistributionListId) {
return false;
}
const members = membersByListId.get(message.storyDistributionListId);
if (!members) {
return false;
}
return members.has(requesterUuid);
});
if (!isInDistributionList) {
log.warn(
`onRetryRequest/${logId}: requesterUuid is not in distribution list`
);
confirm();
return; return;
} }
} }
const story = Boolean(contentProto.storyMessage);
const recipientConversation = window.ConversationController.getOrCreate( const recipientConversation = window.ConversationController.getOrCreate(
requesterUuid, requesterUuid,
'private' 'private'
); );
const sendOptions = await getSendOptions(recipientConversation.attributes); const sendOptions = await getSendOptions(recipientConversation.attributes, {
story,
});
const promise = messaging.sendMessageProtoAndWait({ const promise = messaging.sendMessageProtoAndWait({
contentHint, contentHint,
groupId, groupId,
@ -192,6 +185,7 @@ export async function onRetryRequest(event: RetryRequestEvent): Promise<void> {
recipients: [requesterUuid], recipients: [requesterUuid],
timestamp, timestamp,
urgent, urgent,
story,
}); });
await handleMessageSend(promise, { await handleMessageSend(promise, {
@ -427,6 +421,88 @@ async function getRetryConversation({
return window.ConversationController.get(conversationId); return window.ConversationController.get(conversationId);
} }
async function checkDistributionListAndAddSKDM({
contentProto,
timestamp,
confirm,
logId,
requesterUuid,
messaging,
}: {
contentProto: Proto.IContent;
timestamp: number;
confirm: () => void;
requesterUuid: string;
logId: string;
messaging: MessageSender;
}): Promise<Proto.IContent | undefined> {
let distributionList: StoryDistributionListDataType | undefined;
const { storyDistributionLists } = window.reduxStore.getState();
const membersByListId = new Map<string, Set<string>>();
const listsById = new Map<string, StoryDistributionListDataType>();
storyDistributionLists.distributionLists.forEach(list => {
membersByListId.set(list.id, new Set(list.memberUuids));
listsById.set(list.id, list);
});
const messages = await dataInterface.getMessagesBySentAt(timestamp);
const isInAnyDistributionList = messages.some(message => {
const listId = message.storyDistributionListId;
if (!listId) {
return false;
}
const members = membersByListId.get(listId);
if (!members) {
return false;
}
const isInList = members.has(requesterUuid);
if (isInList) {
distributionList = listsById.get(listId);
}
return isInList;
});
if (!isInAnyDistributionList) {
log.warn(
`checkDistributionListAndAddSKDM/${logId}: requesterUuid is not in distribution list. Dropping.`
);
confirm();
return undefined;
}
strictAssert(
distributionList,
`checkDistributionListAndAddSKDM/${logId}: Should have a distribution list by this point`
);
const distributionDetails =
await window.Signal.Data.getStoryDistributionWithMembers(
distributionList.id
);
const distributionId = distributionDetails?.senderKeyInfo?.distributionId;
if (!distributionId) {
log.warn(
`onRetryRequest/${logId}: No sender key info for distribution list ${distributionList.id}`
);
return contentProto;
}
const protoWithDistributionMessage =
await messaging.getSenderKeyDistributionMessage(distributionId, {
throwIfNotInDatabase: true,
timestamp,
});
return {
...contentProto,
senderKeyDistributionMessage:
protoWithDistributionMessage.senderKeyDistributionMessage,
};
}
async function maybeAddSenderKeyDistributionMessage({ async function maybeAddSenderKeyDistributionMessage({
contentProto, contentProto,
logId, logId,

View file

@ -8,7 +8,7 @@ import type { UUIDStringType } from '../types/UUID';
import * as log from '../logging/log'; import * as log from '../logging/log';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { DAY, SECOND } from './durations'; import { DAY, SECOND } from './durations';
import { MY_STORIES_ID } from '../types/Stories'; import { getStoriesBlocked, MY_STORIES_ID } from '../types/Stories';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
@ -28,10 +28,17 @@ export async function sendStoryMessage(
conversationIds: Array<string>, conversationIds: Array<string>,
attachment: AttachmentType attachment: AttachmentType
): Promise<void> { ): Promise<void> {
if (getStoriesBlocked()) {
log.warn('stories.sendStoryMessage: stories disabled, returning early');
return;
}
const { messaging } = window.textsecure; const { messaging } = window.textsecure;
if (!messaging) { if (!messaging) {
log.warn('stories.sendStoryMessage: messaging not available'); log.warn(
'stories.sendStoryMessage: messaging not available, returning early'
);
return; return;
} }

View file

@ -99,6 +99,7 @@ export async function sendToGroup({
sendOptions, sendOptions,
sendTarget, sendTarget,
sendType, sendType,
story,
urgent, urgent,
}: { }: {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@ -109,6 +110,7 @@ export async function sendToGroup({
sendOptions?: SendOptionsType; sendOptions?: SendOptionsType;
sendTarget: SenderKeyTargetType; sendTarget: SenderKeyTargetType;
sendType: SendTypesType; sendType: SendTypesType;
story?: boolean;
urgent: boolean; urgent: boolean;
}): Promise<CallbackResultType> { }): Promise<CallbackResultType> {
strictAssert( strictAssert(
@ -141,6 +143,7 @@ export async function sendToGroup({
sendOptions, sendOptions,
sendTarget, sendTarget,
sendType, sendType,
story,
timestamp, timestamp,
urgent, urgent,
}); });
@ -377,7 +380,7 @@ export async function sendToGroupViaSenderKey(options: {
// 4. Partition devices into sender key and non-sender key groups // 4. Partition devices into sender key and non-sender key groups
const [devicesForSenderKey, devicesForNormalSend] = partition( const [devicesForSenderKey, devicesForNormalSend] = partition(
currentDevices, currentDevices,
device => isValidSenderKeyRecipient(memberSet, device.identifier) device => isValidSenderKeyRecipient(memberSet, device.identifier, { story })
); );
const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey); const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey);
@ -513,13 +516,13 @@ export async function sendToGroupViaSenderKey(options: {
contentMessage: Proto.Content.encode(contentMessage).finish(), contentMessage: Proto.Content.encode(contentMessage).finish(),
groupId, groupId,
}); });
const accessKeys = getXorOfAccessKeys(devicesForSenderKey); const accessKeys = getXorOfAccessKeys(devicesForSenderKey, { story });
const result = await window.textsecure.messaging.server.sendWithSenderKey( const result = await window.textsecure.messaging.server.sendWithSenderKey(
messageBuffer, messageBuffer,
accessKeys, accessKeys,
timestamp, timestamp,
{ online, urgent } { online, story, urgent }
); );
const parsed = multiRecipient200ResponseSchema.safeParse(result); const parsed = multiRecipient200ResponseSchema.safeParse(result);
@ -977,7 +980,10 @@ async function handle410Response(
} }
} }
function getXorOfAccessKeys(devices: Array<DeviceType>): Buffer { function getXorOfAccessKeys(
devices: Array<DeviceType>,
{ story }: { story?: boolean } = {}
): Buffer {
const uuids = getUuidsFromDevices(devices); const uuids = getUuidsFromDevices(devices);
const result = Buffer.alloc(ACCESS_KEY_LENGTH); const result = Buffer.alloc(ACCESS_KEY_LENGTH);
@ -994,7 +1000,7 @@ function getXorOfAccessKeys(devices: Array<DeviceType>): Buffer {
); );
} }
const accessKey = getAccessKey(conversation.attributes); const accessKey = getAccessKey(conversation.attributes, { story });
if (!accessKey) { if (!accessKey) {
throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`); throw new Error(`getXorOfAccessKeys: No accessKey for UUID ${uuid}`);
} }
@ -1099,7 +1105,8 @@ async function encryptForSenderKey({
function isValidSenderKeyRecipient( function isValidSenderKeyRecipient(
members: Set<ConversationModel>, members: Set<ConversationModel>,
uuid: string uuid: string,
{ story }: { story?: boolean } = {}
): boolean { ): boolean {
const memberConversation = window.ConversationController.get(uuid); const memberConversation = window.ConversationController.get(uuid);
if (!memberConversation) { if (!memberConversation) {
@ -1121,7 +1128,7 @@ function isValidSenderKeyRecipient(
return false; return false;
} }
if (!getAccessKey(memberConversation.attributes)) { if (!getAccessKey(memberConversation.attributes, { story })) {
return false; return false;
} }
@ -1247,10 +1254,15 @@ async function resetSenderKey(sendTarget: SenderKeyTargetType): Promise<void> {
} }
function getAccessKey( function getAccessKey(
attributes: ConversationAttributesType attributes: ConversationAttributesType,
{ story }: { story?: boolean }
): string | undefined { ): string | undefined {
const { sealedSender, accessKey } = attributes; const { sealedSender, accessKey } = attributes;
if (story) {
return accessKey || ZERO_ACCESS_KEY;
}
if (sealedSender === SEALED_SENDER.ENABLED) { if (sealedSender === SEALED_SENDER.ENABLED) {
return accessKey || undefined; return accessKey || undefined;
} }
@ -1307,11 +1319,13 @@ async function fetchKeysForIdentifier(
); );
try { try {
// Note: we have no way to make an unrestricted unathenticated key fetch as part of a
// story send, so we hardcode story=false.
const { accessKeyFailed } = await getKeysForIdentifier( const { accessKeyFailed } = await getKeysForIdentifier(
identifier, identifier,
window.textsecure?.messaging?.server, window.textsecure?.messaging?.server,
devices, devices,
getAccessKey(emptyConversation.attributes) getAccessKey(emptyConversation.attributes, { story: false })
); );
if (accessKeyFailed) { if (accessKeyFailed) {
log.info( log.info(

View file

@ -1975,10 +1975,10 @@
node-gyp-build "^4.2.3" node-gyp-build "^4.2.3"
uuid "^8.3.0" uuid "^8.3.0"
"@signalapp/mock-server@2.10.0": "@signalapp/mock-server@2.11.0":
version "2.10.0" version "2.11.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.10.0.tgz#a27246e7b912caebc0bef628303e11689bf9b74c" resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.11.0.tgz#fe5f6229c4a5c28b3591e986a1622218452c5112"
integrity sha512-kHos3n8lNBhivUecEFG4g1rvYpJ6oPgzKMOsaI+vN8R1R4Pc63WXxrLsxqAI2QmAngD+nmOgbjwAvKyH4MN0+w== integrity sha512-m23XZ8lrBn0u+zakxkKG5SezyUg6fnWwZewFF28sHNL7fQDVPHJkFCJZgE9XJwHBDM7TYz9ca/ucReW4GIPHoQ==
dependencies: dependencies:
"@signalapp/libsignal-client" "^0.20.0" "@signalapp/libsignal-client" "^0.20.0"
debug "^4.3.2" debug "^4.3.2"