diff --git a/ts/components/search/MessageSearchResults.tsx b/ts/components/search/MessageSearchResults.tsx index 13ac83098..07c62522d 100644 --- a/ts/components/search/MessageSearchResults.tsx +++ b/ts/components/search/MessageSearchResults.tsx @@ -1,15 +1,15 @@ import React from 'react'; import styled, { CSSProperties } from 'styled-components'; +import { useConversationUsername, useIsPrivate } from '../../hooks/useParamSelector'; +import { MessageAttributes } from '../../models/messageType'; +import { UserUtils } from '../../session/utils'; import { getOurPubKeyStrFromCache } from '../../session/utils/User'; import { openConversationToSpecificMessage } from '../../state/ducks/conversations'; -import { ContactName } from '../conversation/ContactName'; import { Avatar, AvatarSize } from '../avatar/Avatar'; -import { Timestamp } from '../conversation/Timestamp'; import { MessageBodyHighlight } from '../basic/MessageBodyHighlight'; -import { MessageAttributes } from '../../models/messageType'; -import { useConversationUsername, useIsPrivate } from '../../hooks/useParamSelector'; -import { UserUtils } from '../../session/utils'; +import { ContactName } from '../conversation/ContactName'; +import { Timestamp } from '../conversation/Timestamp'; export type MessageResultProps = MessageAttributes & { snippet: string }; diff --git a/ts/data/data.ts b/ts/data/data.ts index 403b9c72e..4ddca7312 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -26,6 +26,12 @@ import { channels } from './channels'; import * as dataInit from './dataInit'; import { cleanData } from './dataUtils'; import { SNODE_POOL_ITEM_ID } from './settings-key'; +import { + DataCallArgs, + DeleteAllMessageFromSendersInConversationType, + DeleteAllMessageHashesInConversationMatchingAuthorType, + DeleteAllMessageHashesInConversationType, +} from './sharedDataTypes'; const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; @@ -586,6 +592,24 @@ async function removeAllMessagesInConversation(conversationId: string): Promise< ); } +async function deleteAllMessageFromSendersInConversation( + args: DataCallArgs +): ReturnType { + return channels.deleteAllMessageFromSendersInConversation(args); +} + +async function deleteAllMessageHashesInConversation( + args: DataCallArgs +): ReturnType { + return channels.deleteAllMessageHashesInConversation(args); +} + +async function deleteAllMessageHashesInConversationMatchingAuthor( + args: DataCallArgs +): ReturnType { + return channels.deleteAllMessageHashesInConversationMatchingAuthor(args); +} + async function getMessagesBySentAt(sentAt: number): Promise { const messages = await channels.getMessagesBySentAt(sentAt); return new MessageCollection(messages); @@ -866,6 +890,9 @@ export const Data = { getLastHashBySnode, getSeenMessagesByHashList, removeAllMessagesInConversation, + deleteAllMessageFromSendersInConversation, + deleteAllMessageHashesInConversation, + deleteAllMessageHashesInConversationMatchingAuthor, getMessagesBySentAt, getExpiredMessages, getOutgoingWithoutExpiresAt, diff --git a/ts/data/dataInit.ts b/ts/data/dataInit.ts index 8e14cf548..9c2f7ca95 100644 --- a/ts/data/dataInit.ts +++ b/ts/data/dataInit.ts @@ -50,6 +50,9 @@ const channelsToMake = new Set([ 'getUnreadCountByConversation', 'getMessageCountByType', 'removeAllMessagesInConversation', + 'deleteAllMessageFromSendersInConversation', + 'deleteAllMessageHashesInConversation', + 'deleteAllMessageHashesInConversationMatchingAuthor', 'getMessageCount', 'filterAlreadyFetchedOpengroupMessage', 'getMessagesBySenderAndSentAt', diff --git a/ts/data/sharedDataTypes.ts b/ts/data/sharedDataTypes.ts new file mode 100644 index 000000000..d74153aeb --- /dev/null +++ b/ts/data/sharedDataTypes.ts @@ -0,0 +1,24 @@ +import { PubkeyType, WithGroupPubkey } from 'libsession_util_nodejs'; + +type PrArrayMsgIds = Promise>; + +export type DataCallArgs any> = Parameters[0]; + +export type DeleteAllMessageFromSendersInConversationType = ( + args: WithGroupPubkey & { + toRemove: Array; + } +) => PrArrayMsgIds; + +export type DeleteAllMessageHashesInConversationType = ( + args: WithGroupPubkey & { + messageHashes: Array; + } +) => PrArrayMsgIds; + +export type DeleteAllMessageHashesInConversationMatchingAuthorType = ( + args: WithGroupPubkey & { + messageHashes: Array; + author: PubkeyType; + } +) => PrArrayMsgIds; diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index c9da1613b..36d757edd 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -1,4 +1,5 @@ -import { compact } from 'lodash'; +import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; +import { compact, isEmpty } from 'lodash'; import { SessionButtonColor } from '../../components/basic/SessionButton'; import { Data } from '../../data/data'; import { ConversationModel } from '../../models/conversation'; @@ -6,41 +7,37 @@ import { MessageModel } from '../../models/message'; import { getMessageQueue } from '../../session'; import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI'; +import { GetNetworkTime } from '../../session/apis/snode_api/getNetworkTime'; import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; import { ConvoHub } from '../../session/conversations'; +import { getSodiumRenderer } from '../../session/crypto'; import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage'; +import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; import { ed25519Str } from '../../session/onions/onionPath'; import { PubKey } from '../../session/types'; import { ToastUtils, UserUtils } from '../../session/utils'; import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; import { resetRightOverlayMode } from '../../state/ducks/section'; +import { MetaGroupWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; -/** - * Deletes messages for everyone in a 1-1 or everyone in a closed group conversation. - */ -async function unsendMessagesForEveryone( +async function unsendMessagesForEveryone1o1AndLegacy( conversation: ConversationModel, + destination: PubkeyType, msgsToDelete: Array ) { - window?.log?.info('Deleting messages for all users in this conversation'); - const destinationId = conversation.id; - if (!destinationId) { - return; - } - if (conversation.isOpenGroupV2()) { - throw new Error( - 'Cannot unsend a message for an opengroup v2. This has to be a deleteMessage api call' - ); + const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); + + if (conversation.isClosedGroupV2()) { + throw new Error('unsendMessagesForEveryone1o1AndLegacy not compatible with group v2'); } - const unsendMsgObjects = getUnsendMessagesObjects(msgsToDelete); if (conversation.isPrivate()) { // sending to recipient all the messages separately for now await Promise.all( unsendMsgObjects.map(unsendObject => getMessageQueue() - .sendToPubKey(new PubKey(destinationId), unsendObject, SnodeNamespaces.Default) + .sendToPubKey(new PubKey(destination), unsendObject, SnodeNamespaces.Default) .catch(window?.log?.error) ) ); @@ -51,7 +48,9 @@ async function unsendMessagesForEveryone( .catch(window?.log?.error) ) ); - } else if (conversation.isClosedGroup()) { + return; + } + if (conversation.isClosedGroup()) { // sending to recipient all the messages separately for now await Promise.all( unsendMsgObjects.map(unsendObject => { @@ -59,19 +58,84 @@ async function unsendMessagesForEveryone( .sendToGroup({ message: unsendObject, namespace: SnodeNamespaces.LegacyClosedGroup, - groupPubKey: new PubKey(destinationId), + groupPubKey: new PubKey(destination), }) .catch(window?.log?.error); }) ); } +} + +async function unsendMessagesForEveryoneGroupV2( + conversation: ConversationModel, + groupPk: GroupPubkeyType, + msgsToDelete: Array +) { + const messageHashesToUnsend = await getMessageHashes(msgsToDelete); + const group = await MetaGroupWrapperActions.infoGet(groupPk); + + if (!messageHashesToUnsend.length) { + window.log.info('unsendMessagesForEveryoneGroupV2: no hashes to remove'); + return; + } + + if (!conversation.isClosedGroupV2()) { + throw new Error('unsendMessagesForEveryoneGroupV2 needs a group v2'); + } + + await getMessageQueue().sendToGroupV2NonDurably({ + message: new GroupUpdateDeleteMemberContentMessage({ + createAtNetworkTimestamp: GetNetworkTime.now(), + expirationType: 'unknown', + expireTimer: 0, + groupPk, + memberSessionIds: [], + messageHashes: messageHashesToUnsend, + sodium: await getSodiumRenderer(), + secretKey: group.secretKey, + }), + }); +} + +/** + * Deletes messages for everyone in a 1-1 or everyone in a closed group conversation. + */ +async function unsendMessagesForEveryone( + conversation: ConversationModel, + msgsToDelete: Array +) { + window?.log?.info('Deleting messages for all users in this conversation'); + const destinationId = conversation.id as string; + if (!destinationId) { + return; + } + if (conversation.isOpenGroupV2()) { + throw new Error( + 'Cannot unsend a message for an opengroup v2. This has to be a deleteMessage api call' + ); + } + + if ( + conversation.isPrivate() || + (conversation.isClosedGroup() && !conversation.isClosedGroupV2()) + ) { + if (!PubKey.is05Pubkey(conversation.id)) { + throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key'); + } + await unsendMessagesForEveryone1o1AndLegacy(conversation, conversation.id, msgsToDelete); + } else if (conversation.isClosedGroupV2()) { + if (!PubKey.is03Pubkey(destinationId)) { + throw new Error('invalid conversation id (03) for unsendMessageForEveryone'); + } + await unsendMessagesForEveryoneGroupV2(conversation, destinationId, msgsToDelete); + } await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete); window.inboxStore?.dispatch(resetSelectedMessageIds()); ToastUtils.pushDeleted(msgsToDelete.length); } -function getUnsendMessagesObjects(messages: Array) { +function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array) { // #region building request return compact( messages.map(message => { @@ -95,6 +159,14 @@ function getUnsendMessagesObjects(messages: Array) { // #endregion } +async function getMessageHashes(messages: Array) { + return compact( + messages.map(message => { + return message.get('messageHash'); + }) + ); +} + /** * Do a single request to the swarm with all the message hashes to delete from the swarm. * @@ -221,7 +293,7 @@ async function unsendMessageJustForThisUser( ) { window?.log?.warn('Deleting messages just for this user'); - const unsendMsgObjects = getUnsendMessagesObjects(msgsToDelete); + const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); // sending to our other devices all the messages separately for now await Promise.all( @@ -302,15 +374,37 @@ const doDeleteSelectedMessages = async ({ return; } - const areAllOurs = selectedMessages.every(message => ourDevicePubkey === message.getSource()); + const areAllOurs = selectedMessages.every(message => message.getSource() === ourDevicePubkey); if (conversation.isPublic()) { await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation, areAllOurs); return; } - // #region deletion for 1-1 and closed groups + /** + * Note: groupv2 support deleteForEveryone only. + * For groupv2, a user can delete only his messages, but an admin can delete the messages of anyone. + * */ + if (deleteForEveryone || conversation.isClosedGroupV2()) { + if (conversation.isClosedGroupV2()) { + const convoId = conversation.id; + if (!PubKey.is03Pubkey(convoId)) { + throw new Error('unsend request for groupv2 but not a 03 key is impossible possible'); + } + // only lookup adminKey if we need to + if (!areAllOurs) { + const group = await MetaGroupWrapperActions.infoGet(convoId); + const weAreAdmin = !isEmpty(group.secretKey); + if (!weAreAdmin) { + ToastUtils.pushMessageDeleteForbidden(); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + return; + } + } + // if they are all ours, of not but we are an admin, we can move forward + await unsendMessagesForEveryone(conversation, selectedMessages); + return; + } - if (deleteForEveryone) { if (!areAllOurs) { ToastUtils.pushMessageDeleteForbidden(); window.inboxStore?.dispatch(resetSelectedMessageIds()); @@ -320,7 +414,7 @@ const doDeleteSelectedMessages = async ({ return; } - // delete just for me in a closed group only means delete locally + // delete just for me in a legacy closed group only means delete locally if (conversation.isClosedGroup()) { await deleteMessagesFromSwarmAndCompletelyLocally(conversation, selectedMessages); @@ -331,8 +425,6 @@ const doDeleteSelectedMessages = async ({ } // otherwise, delete that message locally, from our swarm and from our other devices await unsendMessageJustForThisUser(conversation, selectedMessages); - - // #endregion }; export async function deleteMessagesByIdForEveryone( diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 899443e6e..31be43192 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -242,7 +242,7 @@ export class ConversationModel extends Backbone.Model { ); } - public isClosedGroupV2(): boolean { + public isClosedGroupV2() { return Boolean(this.get('type') === ConversationTypeEnum.GROUPV2 && PubKey.is03Pubkey(this.id)); } @@ -1097,6 +1097,9 @@ export class ConversationModel extends Backbone.Model { if (this.isClosedGroup()) { if (this.isAdmin(UserUtils.getOurPubKeyStrFromCache())) { if (this.isClosedGroupV2()) { + if (!PubKey.is03Pubkey(this.id)) { + throw new Error('updateExpireTimer v2 group requires a 03 key'); + } const group = await UserGroupsWrapperActions.getGroup(this.id); if (!group || !group.secretKey) { throw new Error( @@ -2162,6 +2165,9 @@ export class ConversationModel extends Backbone.Model { } private async sendMessageToGroupV2(chatMessageParams: VisibleMessageParams) { + if (!PubKey.is03Pubkey(this.id)) { + throw new Error('sendMessageToGroupV2 needs a 03 key'); + } const visibleMessage = new VisibleMessage(chatMessageParams); const groupVisibleMessage = new ClosedGroupV2VisibleMessage({ chatMessage: visibleMessage, diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 46cef9b81..a06b963fb 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -105,6 +105,7 @@ const LOKI_SCHEMA_VERSIONS = [ updateToSessionSchemaVersion34, updateToSessionSchemaVersion35, updateToSessionSchemaVersion36, + updateToSessionSchemaVersion37, ]; function updateToSessionSchemaVersion1(currentVersion: number, db: BetterSqlite3.Database) { @@ -1952,6 +1953,38 @@ function updateToSessionSchemaVersion36(currentVersion: number, db: BetterSqlite console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); } +function updateToSessionSchemaVersion37(currentVersion: number, db: BetterSqlite3.Database) { + const targetVersion = 37; + if (currentVersion >= targetVersion) { + return; + } + + console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`); + + db.transaction(() => { + db.exec(`ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN messageHash TEXT; + UPDATE ${MESSAGES_TABLE} SET + messageHash = json_extract(json, '$.messageHash'); + `); + + db.exec(`CREATE INDEX messages_t_messageHash ON ${MESSAGES_TABLE} ( + messageHash + );`); + db.exec(`CREATE INDEX messages_t_messageHash_author ON ${MESSAGES_TABLE} ( + messageHash, + source + );`); + db.exec(`CREATE INDEX messages_t_messageHash_author_convoId ON ${MESSAGES_TABLE} ( + messageHash, + source, + conversationId + );`); + writeSessionSchemaVersion(targetVersion, db); + })(); + + console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); +} + export function printTableColumns(table: string, db: BetterSqlite3.Database) { console.info(db.pragma(`table_info('${table}');`)); } diff --git a/ts/node/migration/signalMigrations.ts b/ts/node/migration/signalMigrations.ts index 1dc919e46..6d7bddcd7 100644 --- a/ts/node/migration/signalMigrations.ts +++ b/ts/node/migration/signalMigrations.ts @@ -1,6 +1,6 @@ -import path from 'path'; import * as BetterSqlite3 from '@signalapp/better-sqlite3'; import { isNumber } from 'lodash'; +import path from 'path'; import { ATTACHMENT_DOWNLOADS_TABLE, diff --git a/ts/node/sql.ts b/ts/node/sql.ts index 51b8c74cc..fea3a6f85 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -53,6 +53,7 @@ import { StorageItem } from './storage_item'; // checked - only node import { OpenGroupV2Room } from '../data/opengroups'; import { + AwaitedReturn, CONFIG_DUMP_TABLE, MsgDuplicateSearchOpenGroup, roomHasBlindEnabled, @@ -63,6 +64,12 @@ import { } from '../types/sqlSharedTypes'; import { KNOWN_BLINDED_KEYS_ITEM, SettingsKey } from '../data/settings-key'; +import { + DataCallArgs, + DeleteAllMessageFromSendersInConversationType, + DeleteAllMessageHashesInConversationMatchingAuthorType, + DeleteAllMessageHashesInConversationType, +} from '../data/sharedDataTypes'; import { MessageAttributes } from '../models/messageType'; import { SignalService } from '../protobuf'; import { Quote } from '../receiver/types'; @@ -804,6 +811,7 @@ function saveMessage(data: MessageAttributes) { expireTimer, expirationStartTimestamp, flags, + messageHash, } = data; if (!id) { @@ -836,6 +844,7 @@ function saveMessage(data: MessageAttributes) { type: type || '', unread, flags: flags ?? 0, + messageHash, }; assertGlobalInstance() @@ -860,7 +869,8 @@ function saveMessage(data: MessageAttributes) { source, type, unread, - flags + flags, + messageHash ) values ( $id, $json, @@ -881,7 +891,8 @@ function saveMessage(data: MessageAttributes) { $source, $type, $unread, - $flags + $flags, + $messageHash );` ) .run(payload); @@ -1047,6 +1058,56 @@ function removeAllMessagesInConversation( .run({ conversationId }); } +function deleteAllMessageFromSendersInConversation( + { groupPk, toRemove }: DataCallArgs, + instance?: BetterSqlite3.Database +): AwaitedReturn { + if (!groupPk || !toRemove.length) { + return []; + } + return assertGlobalInstanceOrInstance(instance) + .prepare( + `DELETE FROM ${MESSAGES_TABLE} WHERE conversationId = $conversationId AND source IN ( ${toRemove.map(() => '?').join(', ')} ) RETURNING id` + ) + .all(groupPk, toRemove) + .map(m => m.id); +} + +function deleteAllMessageHashesInConversation( + { groupPk, messageHashes }: DataCallArgs, + instance?: BetterSqlite3.Database +): AwaitedReturn { + if (!groupPk || !messageHashes.length) { + return []; + } + return assertGlobalInstanceOrInstance(instance) + .prepare( + `DELETE FROM ${MESSAGES_TABLE} WHERE conversationId = ? AND messageHash IN ( ${messageHashes.map(() => '?').join(', ')} ) RETURNING id` + ) + .all(groupPk, ...messageHashes) + .map(m => m.id); +} + +function deleteAllMessageHashesInConversationMatchingAuthor( + { + author, + groupPk, + messageHashes, + }: DataCallArgs, + instance?: BetterSqlite3.Database +): AwaitedReturn { + if (!groupPk || !author || !messageHashes.length) { + return []; + } + console.warn('messageHashes', messageHashes); + return assertGlobalInstanceOrInstance(instance) + .prepare( + `DELETE FROM ${MESSAGES_TABLE} WHERE conversationId = ? AND source = ? AND messageHash IN ( ${messageHashes.map(() => '?').join(', ')} ) RETURNING id` + ) + .all(groupPk, author, ...messageHashes) + .map(m => m.id); +} + function cleanUpExpirationTimerUpdateHistory( conversationId: string, isPrivate: boolean, @@ -2541,6 +2602,9 @@ export const sqlNode = { getAllMessagesWithAttachmentsInConversationSentBefore, cleanUpExpirationTimerUpdateHistory, removeAllMessagesInConversation, + deleteAllMessageFromSendersInConversation, + deleteAllMessageHashesInConversation, + deleteAllMessageHashesInConversationMatchingAuthor, getUnreadByConversation, getUnreadDisappearingByConversation, markAllAsReadByConversationNoExpiration, diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index 78ceffddb..dcd8eed5f 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -1,5 +1,6 @@ import { GroupPubkeyType, PubkeyType, WithGroupPubkey } from 'libsession_util_nodejs'; import { isEmpty, isFinite, isNumber } from 'lodash'; +import { Data } from '../../data/data'; import { ConversationTypeEnum } from '../../models/conversationAttributes'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; @@ -19,6 +20,7 @@ import { PreConditionFailed } from '../../session/utils/errors'; import { UserSync } from '../../session/utils/job_runners/jobs/UserSyncJob'; import { LibSessionUtil } from '../../session/utils/libsession/libsession_utils'; import { SessionUtilConvoInfoVolatile } from '../../session/utils/libsession/libsession_utils_convo_info_volatile'; +import { messagesExpired } from '../../state/ducks/conversations'; import { groupInfoActions } from '../../state/ducks/metaGroups'; import { toFixedUint8ArrayOfLength } from '../../types/sqlSharedTypes'; import { BlockedNumberController } from '../../util'; @@ -343,24 +345,49 @@ async function handleGroupMemberLeftMessage({ convo.set({ active_at: signatureTimestamp, }); - // debugger TODO We should process this message type even if the sender is blocked + // TODO We should process this message type even if the sender is blocked } async function handleGroupDeleteMemberContentMessage({ groupPk, signatureTimestamp, change, + author, }: GroupUpdateGeneric) { const convo = ConvoHub.use().get(groupPk); if (!convo) { return; } + /** + * When handling a GroupUpdateDeleteMemberContentMessage we need to do a few things. + * When `adminSignature` is empty, + * 1. we only delete the messageHashes which are in the change.messageHashes AND sent by that same author. + * When `adminSignature` is not empty and valid, + * 2. we delete all the messages in the group sent by any of change.memberSessionIds AND + * 3. we delete all the messageHashes in the conversation matching the change.messageHashes (even if not from the right sender) + */ + + if (isEmpty(change.adminSignature)) { + // this is step 1. + const msgsDeleted = await Data.deleteAllMessageHashesInConversationMatchingAuthor({ + author, + groupPk, + messageHashes: change.messageHashes, + }); + + window.inboxStore.dispatch( + messagesExpired(msgsDeleted.map(m => ({ conversationKey: groupPk, messageId: m }))) + ); + convo.updateLastMessage(); + return; + } + const sigValid = await verifySig({ pubKey: HexString.fromHexStringNoPrefix(groupPk), signature: change.adminSignature, data: stringToUint8Array( - `DELETE_CONTENT${signatureTimestamp}${change.memberSessionIds.join()}${change.messageHashes.join()}` + `DELETE_CONTENT${signatureTimestamp}${change.memberSessionIds.join('')}${change.messageHashes.join('')}` ), }); @@ -369,11 +396,27 @@ async function handleGroupDeleteMemberContentMessage({ return; } + const toRemove = change.memberSessionIds.filter(PubKey.is05Pubkey); + + const deletedBySenders = await Data.deleteAllMessageFromSendersInConversation({ + groupPk, + toRemove, + }); // this is step 2. + const deletedByHashes = await Data.deleteAllMessageHashesInConversation({ + groupPk, + messageHashes: change.messageHashes, + }); // this is step 3. + + window.inboxStore.dispatch( + messagesExpired( + [...deletedByHashes, ...deletedBySenders].map(m => ({ + conversationKey: groupPk, + messageId: m, + })) + ) + ); + convo.updateLastMessage(); // TODO we should process this message type even if the sender is blocked - convo.set({ - active_at: signatureTimestamp, - }); - throw new Error('Not implemented'); } async function handleGroupUpdateDeleteMessage({ @@ -389,7 +432,7 @@ async function handleGroupUpdateDeleteMessage({ const sigValid = await verifySig({ pubKey: HexString.fromHexStringNoPrefix(groupPk), signature: change.adminSignature, - data: stringToUint8Array(`DELETE${signatureTimestamp}${change.memberSessionIds.join()}`), + data: stringToUint8Array(`DELETE${signatureTimestamp}${change.memberSessionIds.join('')}`), }); if (!sigValid) { diff --git a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts index cfebd4e60..a757094ec 100644 --- a/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts +++ b/ts/session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage.ts @@ -2,36 +2,47 @@ import { PubkeyType } from 'libsession_util_nodejs'; import { isEmpty } from 'lodash'; import { SignalService } from '../../../../../../protobuf'; import { SnodeNamespaces } from '../../../../../apis/snode_api/namespaces'; +import { stringToUint8Array } from '../../../../../utils/String'; import { Preconditions } from '../../../preconditions'; -import { GroupUpdateMessage, GroupUpdateMessageParams } from '../GroupUpdateMessage'; +import { + AdminSigDetails, + GroupUpdateMessage, + GroupUpdateMessageParams, +} from '../GroupUpdateMessage'; -type Params = GroupUpdateMessageParams & { - memberSessionIds: Array; - adminSignature: Uint8Array; // this is a signature of `"DELETE_CONTENT" || timestamp || sessionId[0] || ... || sessionId[N]` -}; +// Note: `Partial` because that message can also be sent as a non-admin and we always give sodium but not always the secretKey +type Params = GroupUpdateMessageParams & + Partial> & + Omit & { + memberSessionIds: Array; + messageHashes: Array; + }; /** * GroupUpdateDeleteMemberContentMessage is sent as a message to group's swarm. */ export class GroupUpdateDeleteMemberContentMessage extends GroupUpdateMessage { + public readonly createAtNetworkTimestamp: Params['createAtNetworkTimestamp']; public readonly memberSessionIds: Params['memberSessionIds']; - public readonly adminSignature: Params['adminSignature']; + public readonly messageHashes: Params['messageHashes']; + public readonly secretKey: Params['secretKey']; + public readonly sodium: Params['sodium']; public readonly namespace = SnodeNamespaces.ClosedGroupMessages; constructor(params: Params) { super(params); - this.adminSignature = params.adminSignature; this.memberSessionIds = params.memberSessionIds; - if (isEmpty(this.memberSessionIds)) { - throw new Error('GroupUpdateDeleteMemberContentMessage needs members in list'); + this.messageHashes = params.messageHashes; + this.secretKey = params.secretKey; + this.createAtNetworkTimestamp = params.createAtNetworkTimestamp; + this.sodium = params.sodium; + + if (isEmpty(this.memberSessionIds) && isEmpty(this.messageHashes)) { + throw new Error( + 'GroupUpdateDeleteMemberContentMessage needs members or messageHashes to be filled' + ); } - Preconditions.checkUin8tArrayOrThrow({ - data: this.adminSignature, - expectedLength: 64, - varName: 'adminSignature', - context: this.constructor.toString(), - }); Preconditions.checkArrayHaveOnly05Pubkeys({ arr: this.memberSessionIds, @@ -41,9 +52,21 @@ export class GroupUpdateDeleteMemberContentMessage extends GroupUpdateMessage { } public dataProto(): SignalService.DataMessage { + // If we have the secretKey, we can delete it for anyone `"DELETE_CONTENT" || timestamp || sessionId[0] || ... || messageHashes[0] || ...` + + let adminSignature = new Uint8Array(); + if (this.secretKey && this.sodium) { + adminSignature = this.sodium.crypto_sign_detached( + stringToUint8Array( + `DELETE_CONTENT${this.createAtNetworkTimestamp}${this.memberSessionIds.join('')}${this.messageHashes.join('')}` + ), + this.secretKey + ); + } const deleteMemberContent = new SignalService.GroupUpdateDeleteMemberContentMessage({ - adminSignature: this.adminSignature, + adminSignature, memberSessionIds: this.memberSessionIds, + messageHashes: this.messageHashes, }); return new SignalService.DataMessage({ groupUpdateMessage: { deleteMemberContent } }); diff --git a/ts/session/utils/AttachmentsDownload.ts b/ts/session/utils/AttachmentsDownload.ts index 82d017f7e..3dc61d7c5 100644 --- a/ts/session/utils/AttachmentsDownload.ts +++ b/ts/session/utils/AttachmentsDownload.ts @@ -8,10 +8,8 @@ import { downloadAttachment, downloadAttachmentSogsV3 } from '../../receiver/att import { initializeAttachmentLogic, processNewAttachment } from '../../types/MessageAttachment'; import { getAttachmentMetadata } from '../../types/message/initializeAttachmentMetadata'; import { AttachmentDownloadMessageDetails } from '../../types/sqlSharedTypes'; -import { MetaGroupWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; import { was404Error } from '../apis/snode_api/onions'; import * as Constants from '../constants'; -import { PubKey } from '../types'; // this may cause issues if we increment that value to > 1, but only having one job will block the whole queue while one attachment is downloading const MAX_ATTACHMENT_JOB_PARALLELISM = 3; @@ -139,34 +137,6 @@ async function _maybeStartJob() { } } -async function shouldSkipGroupAttachmentDownload({ - groupPk, - messageModel, -}: { - groupPk: string; - messageModel: MessageModel; -}) { - if (!PubKey.is03Pubkey(groupPk)) { - return false; - } - try { - const infos = await MetaGroupWrapperActions.infoGet(groupPk); - const sentAt = messageModel.get('sent_at'); - if (!sentAt) { - return false; - } - if ( - (infos.deleteAttachBeforeSeconds && sentAt <= infos.deleteAttachBeforeSeconds * 1000) || - (infos.deleteBeforeSeconds && sentAt <= infos.deleteBeforeSeconds * 1000) - ) { - return true; - } - } catch (e) { - window.log.warn('shouldSkipGroupAttachmentDownload failed with ', e.message); - } - return false; // try to download it -} - async function _runJob(job: any) { const { id, messageId, attachment, type, index, attempts, isOpenGroupV2, openGroupV2Details } = job || {}; @@ -182,16 +152,6 @@ async function _runJob(job: any) { await _finishJob(null, id); return; } - const shouldSkipJobForGroup = await shouldSkipGroupAttachmentDownload({ - groupPk: found.get('conversationId'), - messageModel: found, - }); - - if (shouldSkipJobForGroup) { - logger.info('_runJob: shouldSkipGroupAttachmentDownload is true, deleting job'); - await _finishJob(null, id); - return; - } const isTrusted = found.isTrustedForAttachmentDownload(); diff --git a/ts/test/session/unit/swarm_polling/SwarmPolling_pollForAllKeys_test.ts b/ts/test/session/unit/swarm_polling/SwarmPolling_pollForAllKeys_test.ts index 4fea21649..53d1440ac 100644 --- a/ts/test/session/unit/swarm_polling/SwarmPolling_pollForAllKeys_test.ts +++ b/ts/test/session/unit/swarm_polling/SwarmPolling_pollForAllKeys_test.ts @@ -498,7 +498,7 @@ describe('SwarmPolling:pollForAllKeys', () => { ); stubWithLegacyGroups([]); - stubWithGroups([convo.id]); + stubWithGroups([convo.id as GroupPubkeyType]); convo.set('active_at', Date.now()); groupConvoPubkey = PubKey.cast(convo.id as string);