fix: skip new group messages when receved after group was removed

pull/2620/head
Audric Ackermann 2 years ago
parent bac2887c28
commit 6f6620f622

@ -1,51 +1,54 @@
import React from 'react'; import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { getConversationController } from '../../session/conversations'; import { getConversationController } from '../../session/conversations';
import { leaveClosedGroup } from '../../session/group/closed-group';
import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog'; import { adminLeaveClosedGroup } from '../../state/ducks/modalDialog';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton'; import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SpacerLG } from '../basic/Text'; import { SpacerLG } from '../basic/Text';
import { SessionWrapperModal } from '../SessionWrapperModal'; import { SessionSpinner } from '../basic/SessionSpinner';
type Props = {
conversationId: string;
};
const StyledWarning = styled.p` const StyledWarning = styled.p`
max-width: 500px; max-width: 500px;
line-height: 1.3333; line-height: 1.3333;
`; `;
export const AdminLeaveClosedGroupDialog = (props: Props) => { export const AdminLeaveClosedGroupDialog = (props: { conversationId: string }) => {
const dispatch = useDispatch();
const convo = getConversationController().get(props.conversationId); const convo = getConversationController().get(props.conversationId);
const titleText = `${window.i18n('leaveGroup')} ${convo.getRealSessionUsername()}`; const [loading, setLoading] = useState(false);
const warningAsAdmin = `${window.i18n('leaveGroupConfirmationAdmin')}`; const titleText = `${window.i18n('leaveGroup')} ${convo?.getRealSessionUsername() || ''}`;
const okText = window.i18n('leaveAndRemoveForEveryone');
const cancelText = window.i18n('cancel');
const onClickOK = async () => { const closeDialog = () => {
await leaveClosedGroup(props.conversationId); dispatch(adminLeaveClosedGroup(null));
closeDialog();
}; };
const closeDialog = () => { const onClickOK = async () => {
window.inboxStore?.dispatch(adminLeaveClosedGroup(null)); if (loading) {
return;
}
setLoading(true);
// we know want to delete a closed group right after we've left it, so we can call the deleteContact which takes care of it all
await getConversationController().deleteContact(props.conversationId, false);
setLoading(false);
closeDialog();
}; };
return ( return (
<SessionWrapperModal title={titleText} onClose={closeDialog}> <SessionWrapperModal title={titleText} onClose={closeDialog}>
<SpacerLG /> <SpacerLG />
<StyledWarning>{warningAsAdmin}</StyledWarning> <StyledWarning>{window.i18n('leaveGroupConfirmationAdmin')}</StyledWarning>
<SessionSpinner loading={loading} />
<div className="session-modal__button-group"> <div className="session-modal__button-group">
<SessionButton <SessionButton
text={okText} text={window.i18n('leaveAndRemoveForEveryone')}
buttonColor={SessionButtonColor.Danger} buttonColor={SessionButtonColor.Danger}
buttonType={SessionButtonType.Simple} buttonType={SessionButtonType.Simple}
onClick={onClickOK} onClick={onClickOK}
/> />
<SessionButton <SessionButton
text={cancelText} text={window.i18n('cancel')}
buttonType={SessionButtonType.Simple} buttonType={SessionButtonType.Simple}
onClick={closeDialog} onClick={closeDialog}
/> />

@ -7,13 +7,16 @@ import { CallManager, SyncUtils, ToastUtils, UserUtils } from '../session/utils'
import { SessionButtonColor } from '../components/basic/SessionButton'; import { SessionButtonColor } from '../components/basic/SessionButton';
import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings'; import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings';
import { Data } from '../data/data'; import { Data } from '../data/data';
import { SettingsKey } from '../data/settings-key';
import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi'; import { uploadFileToFsWithOnionV4 } from '../session/apis/file_server_api/FileServerApi';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
import { getConversationController } from '../session/conversations'; import { getConversationController } from '../session/conversations';
import { getSodiumRenderer } from '../session/crypto'; import { getSodiumRenderer } from '../session/crypto';
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager'; import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob';
import { perfEnd, perfStart } from '../session/utils/Performance'; import { perfEnd, perfStart } from '../session/utils/Performance';
import { fromHexToArray, toHex } from '../session/utils/String'; import { fromHexToArray, toHex } from '../session/utils/String';
import { ConfigurationSync } from '../session/utils/job_runners/jobs/ConfigurationSyncJob';
import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils'; import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
import { import {
conversationReset, conversationReset,
@ -32,17 +35,13 @@ import {
updateRemoveModeratorsModal, updateRemoveModeratorsModal,
} from '../state/ducks/modalDialog'; } from '../state/ducks/modalDialog';
import { MIME } from '../types'; import { MIME } from '../types';
import { urlToBlob } from '../types/attachments/VisualAttachment';
import { processNewAttachment } from '../types/MessageAttachment';
import { IMAGE_JPEG } from '../types/MIME'; import { IMAGE_JPEG } from '../types/MIME';
import { processNewAttachment } from '../types/MessageAttachment';
import { urlToBlob } from '../types/attachments/VisualAttachment';
import { BlockedNumberController } from '../util/blockedNumberController'; import { BlockedNumberController } from '../util/blockedNumberController';
import { encryptProfile } from '../util/crypto/profileEncrypter'; import { encryptProfile } from '../util/crypto/profileEncrypter';
import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
import { OpenGroupUtils } from '../session/apis/open_group_api/utils';
import { leaveClosedGroup } from '../session/group/closed-group';
import { SessionUtilContact } from '../session/utils/libsession/libsession_utils_contacts';
import { SettingsKey } from '../data/settings-key';
import { ReleasedFeatures } from '../util/releaseFeature'; import { ReleasedFeatures } from '../util/releaseFeature';
import { Storage, setLastProfileUpdateTimestamp } from '../util/storage';
import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface';
export async function copyPublicKeyByConvoId(convoId: string) { export async function copyPublicKeyByConvoId(convoId: string) {
@ -250,19 +249,19 @@ export function showLeaveGroupByConvoId(conversationId: string) {
title, title,
message, message,
onClickOk: async () => { onClickOk: async () => {
await leaveClosedGroup(conversation.id); await getConversationController().deleteContact(conversation.id, false);
onClickClose(); onClickClose();
}, },
onClickClose, onClickClose,
}) })
); );
} else { return;
window.inboxStore?.dispatch(
adminLeaveClosedGroup({
conversationId,
})
);
} }
window.inboxStore?.dispatch(
adminLeaveClosedGroup({
conversationId,
})
);
} }
export function showInviteContactByConvoId(conversationId: string) { export function showInviteContactByConvoId(conversationId: string) {
window.inboxStore?.dispatch(updateInviteContactModal({ conversationId })); window.inboxStore?.dispatch(updateInviteContactModal({ conversationId }));

@ -447,8 +447,10 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
return 'read'; return 'read';
} }
const sent = this.get('sent'); const sent = this.get('sent');
// control messages we've sent, synced from the network appear to just have the sent_at field set.
const sentAt = this.get('sent_at');
const sentTo = this.get('sent_to') || []; const sentTo = this.get('sent_to') || [];
if (sent || sentTo.length > 0) { if (sent || sentTo.length > 0 || sentAt) {
return 'sent'; return 'sent';
} }

@ -1431,7 +1431,7 @@ function getBlockedNumbersDuringMigration(db: BetterSqlite3.Database) {
try { try {
const blockedItem = sqlNode.getItemById('blocked', db); const blockedItem = sqlNode.getItemById('blocked', db);
if (!blockedItem) { if (!blockedItem) {
throw new Error('no blocked contacts at all'); return [];
} }
const foundBlocked = blockedItem?.value; const foundBlocked = blockedItem?.value;
console.info('foundBlockedNumbers during migration', foundBlocked); console.info('foundBlockedNumbers during migration', foundBlocked);
@ -1497,6 +1497,15 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
WHERE type = 'private' AND (active_at IS NULL OR active_at = 0 );` WHERE type = 'private' AND (active_at IS NULL OR active_at = 0 );`
).run({}); ).run({});
// create the table which is going to handle the wrappers, without any content in this migration.
db.exec(`CREATE TABLE ${CONFIG_DUMP_TABLE}(
variant TEXT NOT NULL,
publicKey TEXT NOT NULL,
data BLOB,
PRIMARY KEY (publicKey, variant)
);
`);
/** /**
* Remove the `publicChat` prefix from the communities, instead keep the full url+room in it, with the corresponding http or https prefix. * Remove the `publicChat` prefix from the communities, instead keep the full url+room in it, with the corresponding http or https prefix.
* This is easier to handle with the libsession wrappers * This is easier to handle with the libsession wrappers
@ -1531,7 +1540,7 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
newId, newId,
oldId: convoDetails.oldConvoId, oldId: convoDetails.oldConvoId,
}); });
// do the same for messages and where else? // do the same for messages
db.prepare( db.prepare(
`UPDATE ${MESSAGES_TABLE} SET `UPDATE ${MESSAGES_TABLE} SET
@ -1543,19 +1552,11 @@ function updateToSessionSchemaVersion30(currentVersion: number, db: BetterSqlite
db.prepare( db.prepare(
`UPDATE ${OPEN_GROUP_ROOMS_V2_TABLE} SET `UPDATE ${OPEN_GROUP_ROOMS_V2_TABLE} SET
conversationId = $newId, conversationId = $newId,
json = json_set(json, '$.conversationId', $newId);` json = json_set(json, '$.conversationId', $newId)
).run({ newId }); WHERE conversationId = $oldConvoId;`
).run({ newId, oldConvoId: convoDetails.oldConvoId });
}); });
// create the table which is going to handle the wrappers, without any content in this migration.
db.exec(`CREATE TABLE ${CONFIG_DUMP_TABLE}(
variant TEXT NOT NULL,
publicKey TEXT NOT NULL,
data BLOB,
PRIMARY KEY (publicKey, variant)
);
`);
writeSessionSchemaVersion(targetVersion, db); writeSessionSchemaVersion(targetVersion, db);
})(); })();
@ -1712,7 +1713,6 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite
communitiesToWriteInWrapper.forEach(community => { communitiesToWriteInWrapper.forEach(community => {
try { try {
console.info('Writing community: ', JSON.stringify(community));
insertCommunityIntoWrapper( insertCommunityIntoWrapper(
community, community,
userGroupsConfigWrapper, userGroupsConfigWrapper,
@ -1810,7 +1810,7 @@ function updateToSessionSchemaVersion31(currentVersion: number, db: BetterSqlite
// still, we update the schema version // still, we update the schema version
writeSessionSchemaVersion(targetVersion, db); writeSessionSchemaVersion(targetVersion, db);
}); })();
} }
export function printTableColumns(table: string, db: BetterSqlite3.Database) { export function printTableColumns(table: string, db: BetterSqlite3.Database) {

@ -342,6 +342,8 @@ export async function markGroupAsLeftOrKicked(
groupConvo: ConversationModel, groupConvo: ConversationModel,
isKicked: boolean isKicked: boolean
) { ) {
getSwarmPollingInstance().removePubkey(groupPublicKey);
await innerRemoveAllClosedGroupEncryptionKeyPairs(groupPublicKey); await innerRemoveAllClosedGroupEncryptionKeyPairs(groupPublicKey);
if (isKicked) { if (isKicked) {
@ -349,7 +351,6 @@ export async function markGroupAsLeftOrKicked(
} else { } else {
groupConvo.set('left', true); groupConvo.set('left', true);
} }
getSwarmPollingInstance().removePubkey(groupPublicKey);
} }
/** /**
@ -547,13 +548,9 @@ async function performIfValid(
} else if (groupUpdate.type === Type.MEMBER_LEFT) { } else if (groupUpdate.type === Type.MEMBER_LEFT) {
await handleClosedGroupMemberLeft(envelope, convo); await handleClosedGroupMemberLeft(envelope, convo);
} else if (groupUpdate.type === Type.ENCRYPTION_KEY_PAIR_REQUEST) { } else if (groupUpdate.type === Type.ENCRYPTION_KEY_PAIR_REQUEST) {
window?.log?.warn(
'Received ENCRYPTION_KEY_PAIR_REQUEST message but it is not enabled for now.'
);
await removeFromCache(envelope); await removeFromCache(envelope);
// if you add a case here, remember to add it where performIfValid is called too.
} }
// if you add a case here, remember to add it where performIfValid is called too.
return true; return true;
} }

@ -329,7 +329,15 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
for (let index = 0; index < legacyGroupsToLeaveInDB.length; index++) { for (let index = 0; index < legacyGroupsToLeaveInDB.length; index++) {
const toLeave = legacyGroupsToLeaveInDB[index]; const toLeave = legacyGroupsToLeaveInDB[index];
console.warn('leaving legacy group from configuration sync message with convoId ', toLeave.id); console.warn('leaving legacy group from configuration sync message with convoId ', toLeave.id);
await getConversationController().deleteContact(toLeave.id, true); const toLeaveFromDb = getConversationController().get(toLeave.id);
// if we were kicked from that group, leave it as is until the user manually deletes it
// otherwise, completely remove the conversation
if (!toLeaveFromDb?.get('isKickedFromGroup')) {
window.log.debug(`we were kicked from ${toLeave.id} so we keep it until manually deleted`);
await getConversationController().deleteContact(toLeave.id, true);
}
} }
for (let index = 0; index < legacyGroupsToJoinInDB.length; index++) { for (let index = 0; index < legacyGroupsToJoinInDB.length; index++) {
@ -379,10 +387,6 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
let changes = await legacyGroupConvo.setPriorityFromWrapper(fromWrapper.priority, false); let changes = await legacyGroupConvo.setPriorityFromWrapper(fromWrapper.priority, false);
if (legacyGroupConvo.get('priority') !== fromWrapper.priority) {
legacyGroupConvo.set({ priority: fromWrapper.priority });
changes = true;
}
const existingTimestampMs = legacyGroupConvo.get('lastJoinedTimestamp'); const existingTimestampMs = legacyGroupConvo.get('lastJoinedTimestamp');
if (Math.floor(existingTimestampMs / 1000) !== fromWrapper.joinedAtSeconds) { if (Math.floor(existingTimestampMs / 1000) !== fromWrapper.joinedAtSeconds) {
legacyGroupConvo.set({ lastJoinedTimestamp: fromWrapper.joinedAtSeconds * 1000 }); legacyGroupConvo.set({ lastJoinedTimestamp: fromWrapper.joinedAtSeconds * 1000 });
@ -400,29 +404,31 @@ async function handleLegacyGroupUpdate(latestEnvelopeTimestamp: number) {
); );
changes = true; changes = true;
} }
// start polling for this new group // start polling for this group if we haven't left it yet. The wrapper does not store this info for legacy group so we check from the DB entry instead
getSwarmPollingInstance().addGroupId(PubKey.cast(fromWrapper.pubkeyHex)); if (!legacyGroupConvo.get('isKickedFromGroup') && !legacyGroupConvo.get('left')) {
getSwarmPollingInstance().addGroupId(PubKey.cast(fromWrapper.pubkeyHex));
// trigger decrypting of all this group messages we did not decrypt successfully yet. // save the encryption keypair if needed
await queueAllCachedFromSource(fromWrapper.pubkeyHex); if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) {
try {
const inWrapperKeypair: HexKeyPair = {
publicHex: toHex(fromWrapper.encPubkey),
privateHex: toHex(fromWrapper.encSeckey),
};
await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair);
} catch (e) {
window.log.warn('failed to save keypair for legacugroup', fromWrapper.pubkeyHex);
}
}
}
if (changes) { if (changes) {
await legacyGroupConvo.commit(); await legacyGroupConvo.commit();
} }
// save the encryption keypair if needed // trigger decrypting of all this group messages we did not decrypt successfully yet.
if (!isEmpty(fromWrapper.encPubkey) && !isEmpty(fromWrapper.encSeckey)) { await queueAllCachedFromSource(fromWrapper.pubkeyHex);
try {
const inWrapperKeypair: HexKeyPair = {
publicHex: toHex(fromWrapper.encPubkey),
privateHex: toHex(fromWrapper.encSeckey),
};
await addKeyPairToCacheAndDBIfNeeded(fromWrapper.pubkeyHex, inWrapperKeypair);
} catch (e) {
window.log.warn('failed to save keypair for legacugroup', fromWrapper.pubkeyHex);
}
}
} }
} }
@ -458,9 +464,9 @@ async function applyConvoVolatileUpdateFromWrapper(
} }
try { try {
window.log.debug( // window.log.debug(
`applyConvoVolatileUpdateFromWrapper: ${convoId}: forcedUnread:${forcedUnread}, lastReadMessage:${lastReadMessageTimestamp}` // `applyConvoVolatileUpdateFromWrapper: ${convoId}: forcedUnread:${forcedUnread}, lastReadMessage:${lastReadMessageTimestamp}`
); // );
// this should mark all the messages sent before fromWrapper.lastRead as read and update the unreadCount // this should mark all the messages sent before fromWrapper.lastRead as read and update the unreadCount
await foundConvo.markReadFromConfigMessage(lastReadMessageTimestamp); await foundConvo.markReadFromConfigMessage(lastReadMessageTimestamp);
// this commits to the DB, if needed // this commits to the DB, if needed
@ -639,11 +645,13 @@ async function handleConfigMessagesViaLibSession(
} }
window?.log?.debug( window?.log?.debug(
`Handling our sharedConfig message via libsession_util ${configMessages.map(m => ({ `Handling our sharedConfig message via libsession_util ${JSON.stringify(
variant: LibSessionUtil.kindToVariant(m.message.kind), configMessages.map(m => ({
hash: m.messageHash, variant: LibSessionUtil.kindToVariant(m.message.kind),
seqno: (m.message.seqno as Long).toNumber(), hash: m.messageHash,
}))}` seqno: (m.message.seqno as Long).toNumber(),
}))
)}`
); );
const incomingMergeResult = await mergeConfigsWithIncomingUpdates(configMessages); const incomingMergeResult = await mergeConfigsWithIncomingUpdates(configMessages);

@ -1,20 +1,20 @@
import { v4 as uuidv4 } from 'uuid';
import { EnvelopePlus } from './types'; import { EnvelopePlus } from './types';
export { downloadAttachment } from './attachments'; export { downloadAttachment } from './attachments';
import { v4 as uuidv4 } from 'uuid';
import { addToCache, getAllFromCache, getAllFromCacheForSource, removeFromCache } from './cache'; import { addToCache, getAllFromCache, getAllFromCacheForSource, removeFromCache } from './cache';
// innerHandleSwarmContentMessage is only needed because of code duplication in handleDecryptedEnvelope... // innerHandleSwarmContentMessage is only needed because of code duplication in handleDecryptedEnvelope...
import { handleSwarmContentMessage, innerHandleSwarmContentMessage } from './contentMessage';
import _ from 'lodash'; import _ from 'lodash';
import { handleSwarmContentMessage, innerHandleSwarmContentMessage } from './contentMessage';
import { getEnvelopeId } from './common';
import { StringUtils, UserUtils } from '../session/utils';
import { SignalService } from '../protobuf';
import { Data } from '../data/data'; import { Data } from '../data/data';
import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; import { SignalService } from '../protobuf';
import { StringUtils, UserUtils } from '../session/utils';
import { perfEnd, perfStart } from '../session/utils/Performance'; import { perfEnd, perfStart } from '../session/utils/Performance';
import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout';
import { UnprocessedParameter } from '../types/sqlSharedTypes'; import { UnprocessedParameter } from '../types/sqlSharedTypes';
import { getEnvelopeId } from './common';
const incomingMessagePromises: Array<Promise<any>> = []; const incomingMessagePromises: Array<Promise<any>> = [];
@ -147,9 +147,11 @@ export function handleRequest(
*/ */
export async function queueAllCached() { export async function queueAllCached() {
const items = await getAllFromCache(); const items = await getAllFromCache();
items.forEach(item => {
void queueCached(item); await items.reduce(async (promise, item) => {
}); await promise;
await queueCached(item);
}, Promise.resolve());
} }
export async function queueAllCachedFromSource(source: string) { export async function queueAllCachedFromSource(source: string) {

@ -23,7 +23,10 @@ import { SnodeAPIRetrieve } from './retrieveRequest';
import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types'; import { RetrieveMessageItem, RetrieveMessagesResultsBatched } from './types';
import { ReleasedFeatures } from '../../../util/releaseFeature'; import { ReleasedFeatures } from '../../../util/releaseFeature';
import { LibSessionUtil } from '../../utils/libsession/libsession_utils'; import { LibSessionUtil } from '../../utils/libsession/libsession_utils';
import { GenericWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import {
GenericWrapperActions,
UserGroupsWrapperActions,
} from '../../../webworker/workers/browser/libsession_worker_interface';
export function extractWebSocketContent( export function extractWebSocketContent(
message: string, message: string,
@ -171,13 +174,13 @@ export class SwarmPolling {
?.idForLogging() || group.pubkey.key; ?.idForLogging() || group.pubkey.key;
if (diff >= convoPollingTimeout) { if (diff >= convoPollingTimeout) {
window?.log?.info( window?.log?.debug(
`Polling for ${loggingId}; timeout: ${convoPollingTimeout}; diff: ${diff} ` `Polling for ${loggingId}; timeout: ${convoPollingTimeout}; diff: ${diff} `
); );
return this.pollOnceForKey(group.pubkey, true, [SnodeNamespaces.ClosedGroupMessage]); return this.pollOnceForKey(group.pubkey, true, [SnodeNamespaces.ClosedGroupMessage]);
} }
window?.log?.info( window?.log?.debug(
`Not polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}` `Not polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}`
); );
@ -186,7 +189,7 @@ export class SwarmPolling {
try { try {
await Promise.all(concat([directPromise], groupPromises)); await Promise.all(concat([directPromise], groupPromises));
} catch (e) { } catch (e) {
window?.log?.info('pollForAllKeys exception: ', e); window?.log?.warn('pollForAllKeys exception: ', e);
throw e; throw e;
} finally { } finally {
setTimeout(this.pollForAllKeys.bind(this), SWARM_POLLING_TIMEOUT.ACTIVE); setTimeout(this.pollForAllKeys.bind(this), SWARM_POLLING_TIMEOUT.ACTIVE);
@ -270,7 +273,7 @@ export class SwarmPolling {
); );
} }
if (allNamespacesWithoutUserConfigIfNeeded.length) { if (allNamespacesWithoutUserConfigIfNeeded.length) {
window.log.info( window.log.debug(
`received allNamespacesWithoutUserConfigIfNeeded: ${allNamespacesWithoutUserConfigIfNeeded.length}` `received allNamespacesWithoutUserConfigIfNeeded: ${allNamespacesWithoutUserConfigIfNeeded.length}`
); );
} }
@ -280,7 +283,7 @@ export class SwarmPolling {
// if all snodes returned an error (null), no need to update the lastPolledTimestamp // if all snodes returned an error (null), no need to update the lastPolledTimestamp
if (isGroup) { if (isGroup) {
window?.log?.info( window?.log?.debug(
`Polled for group(${ed25519Str(pubkey.key)}):, got ${messages.length} messages back.` `Polled for group(${ed25519Str(pubkey.key)}):, got ${messages.length} messages back.`
); );
let lastPolledTimestamp = Date.now(); let lastPolledTimestamp = Date.now();
@ -306,15 +309,28 @@ export class SwarmPolling {
const newMessages = await this.handleSeenMessages(messages); const newMessages = await this.handleSeenMessages(messages);
perfEnd(`handleSeenMessages-${polledPubkey}`, 'handleSeenMessages'); perfEnd(`handleSeenMessages-${polledPubkey}`, 'handleSeenMessages');
// trigger the handling of all the other messages, not shared config related // don't handle incoming messages from group swarms when using the userconfig and the group is not one of the tracked group
newMessages.forEach(m => { const isUserConfigReleaseLive = await ReleasedFeatures.checkIsUserConfigFeatureReleased();
const content = extractWebSocketContent(m.data, m.hash); if (
if (!content) { isUserConfigReleaseLive &&
return; isGroup &&
} polledPubkey.startsWith('05') &&
!(await UserGroupsWrapperActions.getLegacyGroup(polledPubkey)) // just check if a legacy group with that name exists
) {
// that pubkey is not tracked in the wrapper anymore. Just discard those messages and make sure we are not polling
// TODOLATER we might need to do something like this for the new closed groups once released
await getSwarmPollingInstance().removePubkey(polledPubkey);
} else {
// trigger the handling of all the other messages, not shared config related
newMessages.forEach(m => {
const content = extractWebSocketContent(m.data, m.hash);
if (!content) {
return;
}
Receiver.handleRequest(content.body, isGroup ? polledPubkey : null, content.messageHash); Receiver.handleRequest(content.body, isGroup ? polledPubkey : null, content.messageHash);
}); });
}
} }
private async handleSharedConfigMessages(userConfigMessagesMerged: Array<RetrieveMessageItem>) { private async handleSharedConfigMessages(userConfigMessagesMerged: Array<RetrieveMessageItem>) {

@ -12,12 +12,18 @@ import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/con
import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/conversationAttributes'; import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/conversationAttributes';
import { assertUnreachable } from '../../types/sqlSharedTypes'; import { assertUnreachable } from '../../types/sqlSharedTypes';
import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface';
import { leaveClosedGroup } from '../group/closed-group';
import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJob'; import { ConfigurationSync } from '../utils/job_runners/jobs/ConfigurationSyncJob';
import { LibSessionUtil } from '../utils/libsession/libsession_utils'; import { LibSessionUtil } from '../utils/libsession/libsession_utils';
import { SessionUtilContact } from '../utils/libsession/libsession_utils_contacts'; import { SessionUtilContact } from '../utils/libsession/libsession_utils_contacts';
import { SessionUtilConvoInfoVolatile } from '../utils/libsession/libsession_utils_convo_info_volatile'; import { SessionUtilConvoInfoVolatile } from '../utils/libsession/libsession_utils_convo_info_volatile';
import { SessionUtilUserGroups } from '../utils/libsession/libsession_utils_user_groups'; import { SessionUtilUserGroups } from '../utils/libsession/libsession_utils_user_groups';
import { GetNetworkTime } from '../apis/snode_api/getNetworkTime';
import { getMessageQueue } from '..';
import { markGroupAsLeftOrKicked } from '../../receiver/closedGroups';
import { getSwarmPollingInstance } from '../apis/snode_api';
import { SnodeNamespaces } from '../apis/snode_api/namespaces';
import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage';
import { UserUtils } from '../utils';
let instance: ConversationController | null; let instance: ConversationController | null;
@ -262,6 +268,7 @@ export class ConversationController {
if (roomInfos) { if (roomInfos) {
getOpenGroupManager().removeRoomFromPolledRooms(roomInfos); getOpenGroupManager().removeRoomFromPolledRooms(roomInfos);
} }
await this.cleanUpGroupConversation(conversation.id);
// remove the roomInfos locally for this open group room including the pubkey // remove the roomInfos locally for this open group room including the pubkey
try { try {
@ -272,34 +279,13 @@ export class ConversationController {
break; break;
case 'LegacyGroup': case 'LegacyGroup':
window.log.info(`deleteContact ClosedGroup case: ${conversation.id}`); window.log.info(`deleteContact ClosedGroup case: ${conversation.id}`);
await leaveClosedGroup(conversation.id); await leaveClosedGroup(conversation.id, fromSyncMessage); // this removes the data from the group and convo volatile info
await SessionUtilUserGroups.removeLegacyGroupFromWrapper(conversation.id); await this.cleanUpGroupConversation(conversation.id);
await SessionUtilConvoInfoVolatile.removeLegacyGroupFromWrapper(conversation.id);
break; break;
default: default:
assertUnreachable(convoType, `deleteContact: convoType ${convoType} not handled`); assertUnreachable(convoType, `deleteContact: convoType ${convoType} not handled`);
} }
if (conversation.isGroup()) {
window.log.info(`deleteContact isGroup, removing convo from DB: ${id}`);
// not a private conversation, so not a contact for the ContactWrapper
await Data.removeConversation(id);
window.log.info(`deleteContact isGroup, convo removed from DB: ${id}`);
this.conversations.remove(conversation);
window?.inboxStore?.dispatch(
conversationActions.conversationChanged({
id: conversation.id,
data: conversation.getConversationModelProps(),
})
);
window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id));
window.log.info(`deleteContact NOT private, convo removed from store: ${id}`);
}
if (!fromSyncMessage) { if (!fromSyncMessage) {
await ConfigurationSync.queueNewJobIfNeeded(); await ConfigurationSync.queueNewJobIfNeeded();
} }
@ -400,4 +386,110 @@ export class ConversationController {
} }
this.conversations.reset([]); this.conversations.reset([]);
} }
private async cleanUpGroupConversation(id: string) {
window.log.info(`deleteContact isGroup, removing convo from DB: ${id}`);
// not a private conversation, so not a contact for the ContactWrapper
await Data.removeConversation(id);
window.log.info(`deleteContact isGroup, convo removed from DB: ${id}`);
const conversation = this.conversations.get(id);
if (conversation) {
this.conversations.remove(conversation);
window?.inboxStore?.dispatch(
conversationActions.conversationChanged({
id: id,
data: conversation.getConversationModelProps(),
})
);
}
window.inboxStore?.dispatch(conversationActions.conversationRemoved(id));
window.log.info(`deleteContact NOT private, convo removed from store: ${id}`);
}
}
/**
* You most likely don't want to call this function directly, but instead use the deleteContact() from the ConversationController as it will take care of more cleaningup.
*
* Note: `fromSyncMessage` is used to know if we need to send a leave group message to the group first.
* So if the user made the action on this device, fromSyncMessage should be false, but if it happened from a linked device polled update, set this to true.
*/
async function leaveClosedGroup(groupId: string, fromSyncMessage: boolean) {
const convo = getConversationController().get(groupId);
if (!convo || !convo.isClosedGroup()) {
window?.log?.error('Cannot leave non-existing group');
return;
}
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourNumber);
let members: Array<string> = [];
let admins: Array<string> = [];
// if we are the admin, the group must be destroyed for every members
if (isCurrentUserAdmin) {
window?.log?.info('Admin left a closed group. We need to destroy it');
convo.set({ left: true });
members = [];
admins = [];
} else {
// otherwise, just the exclude ourself from the members and trigger an update with this
convo.set({ left: true });
members = (convo.get('members') || []).filter((m: string) => m !== ourNumber);
admins = convo.get('groupAdmins') || [];
}
convo.set({ members });
await convo.updateGroupAdmins(admins, false);
await convo.commit();
const source = UserUtils.getOurPubKeyStrFromCache();
const networkTimestamp = GetNetworkTime.getNowWithNetworkOffset();
const dbMessage = await convo.addSingleOutgoingMessage({
group_update: { left: [source] },
sent_at: networkTimestamp,
expireTimer: 0,
});
getSwarmPollingInstance().removePubkey(groupId);
if (!fromSyncMessage) {
// Send the update to the group
const ourLeavingMessage = new ClosedGroupMemberLeftMessage({
timestamp: networkTimestamp,
groupId,
identifier: dbMessage.id as string,
});
window?.log?.info(`We are leaving the group ${groupId}. Sending our leaving message.`);
// sent the message to the group and once done, remove everything related to this group
const wasSent = await getMessageQueue().sendToPubKeyNonDurably({
message: ourLeavingMessage,
namespace: SnodeNamespaces.ClosedGroupMessage,
pubkey: PubKey.cast(groupId),
});
window?.log?.info(
`Leaving message sent ${groupId}. Removing everything related to this group.`
);
if (wasSent) {
await cleanUpFullyLeftLegacyGroup(groupId);
}
} else {
await cleanUpFullyLeftLegacyGroup(groupId);
}
}
async function cleanUpFullyLeftLegacyGroup(groupId: string) {
const convo = getConversationController().get(groupId);
await UserGroupsWrapperActions.eraseLegacyGroup(groupId);
await SessionUtilConvoInfoVolatile.removeLegacyGroupFromWrapper(groupId);
if (convo) {
await markGroupAsLeftOrKicked(groupId, convo, false);
}
} }

@ -8,11 +8,7 @@ import { openConversationWithMessages } from '../../state/ducks/conversations';
import { updateConfirmModal } from '../../state/ducks/modalDialog'; import { updateConfirmModal } from '../../state/ducks/modalDialog';
import { getSwarmPollingInstance } from '../apis/snode_api'; import { getSwarmPollingInstance } from '../apis/snode_api';
import { SnodeNamespaces } from '../apis/snode_api/namespaces'; import { SnodeNamespaces } from '../apis/snode_api/namespaces';
import { import { generateClosedGroupPublicKey, generateCurve25519KeyPairWithoutPrefix } from '../crypto';
generateClosedGroupPublicKey,
generateCurve25519KeyPairWithoutPrefix,
generateGroupV3Keypair,
} from '../crypto';
import { import {
ClosedGroupNewMessage, ClosedGroupNewMessage,
ClosedGroupNewMessageParams, ClosedGroupNewMessageParams,
@ -31,13 +27,14 @@ export async function createClosedGroup(groupName: string, members: Array<string
const us = UserUtils.getOurPubKeyStrFromCache(); const us = UserUtils.getOurPubKeyStrFromCache();
const identityKeyPair = await generateGroupV3Keypair(); // const identityKeyPair = await generateGroupV3Keypair();
if (!identityKeyPair) { // if (!identityKeyPair) {
throw new Error('Could not create identity keypair for new closed group v3'); // throw new Error('Could not create identity keypair for new closed group v3');
} // }
// a v3 pubkey starts with 03 and an old one starts with 05 // a v3 pubkey starts with 03 and an old one starts with 05
const groupPublicKey = isV3 ? identityKeyPair.pubkey : await generateClosedGroupPublicKey(); const groupPublicKey = await generateClosedGroupPublicKey();
// const groupPublicKey = isV3 ? identityKeyPair.pubkey : await generateClosedGroupPublicKey();
// the first encryption keypair is generated the same for all versions of closed group // the first encryption keypair is generated the same for all versions of closed group
const encryptionKeyPair = await generateCurve25519KeyPairWithoutPrefix(); const encryptionKeyPair = await generateCurve25519KeyPairWithoutPrefix();

@ -43,6 +43,7 @@ export async function encrypt(
window?.log?.warn("Couldn't get key pair for closed group during encryption"); window?.log?.warn("Couldn't get key pair for closed group during encryption");
throw new Error("Couldn't get key pair for closed group"); throw new Error("Couldn't get key pair for closed group");
} }
const hexPubFromECKeyPair = PubKey.cast(hexEncryptionKeyPair.publicHex); const hexPubFromECKeyPair = PubKey.cast(hexEncryptionKeyPair.publicHex);
const cipherTextClosedGroup = await MessageEncrypter.encryptUsingSessionProtocol( const cipherTextClosedGroup = await MessageEncrypter.encryptUsingSessionProtocol(

@ -2,34 +2,30 @@ import { PubKey } from '../types';
import _ from 'lodash'; import _ from 'lodash';
import { fromHexToArray, toHex } from '../utils/String';
import { BlockedNumberController } from '../../util/blockedNumberController';
import { getConversationController } from '../conversations';
import { Data } from '../../data/data';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { SignalService } from '../../protobuf'; import { getMessageQueue } from '..';
import { generateCurve25519KeyPairWithoutPrefix } from '../crypto'; import { Data } from '../../data/data';
import { encryptUsingSessionProtocol } from '../crypto/MessageEncrypter';
import { ECKeyPair } from '../../receiver/keypairs';
import { UserUtils } from '../utils';
import { ClosedGroupMemberLeftMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupMemberLeftMessage';
import { ConversationModel } from '../../models/conversation'; import { ConversationModel } from '../../models/conversation';
import { ConversationAttributes, ConversationTypeEnum } from '../../models/conversationAttributes';
import { MessageModel } from '../../models/message'; import { MessageModel } from '../../models/message';
import { SignalService } from '../../protobuf';
import { import {
addKeyPairToCacheAndDBIfNeeded, addKeyPairToCacheAndDBIfNeeded,
distributingClosedGroupEncryptionKeyPairs, distributingClosedGroupEncryptionKeyPairs,
markGroupAsLeftOrKicked,
} from '../../receiver/closedGroups'; } from '../../receiver/closedGroups';
import { getMessageQueue } from '..'; import { ECKeyPair } from '../../receiver/keypairs';
import { BlockedNumberController } from '../../util/blockedNumberController';
import { SnodeNamespaces } from '../apis/snode_api/namespaces';
import { getConversationController } from '../conversations';
import { generateCurve25519KeyPairWithoutPrefix } from '../crypto';
import { encryptUsingSessionProtocol } from '../crypto/MessageEncrypter';
import { ClosedGroupAddedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupAddedMembersMessage'; import { ClosedGroupAddedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupAddedMembersMessage';
import { ClosedGroupEncryptionPairMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupEncryptionPairMessage'; import { ClosedGroupEncryptionPairMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupEncryptionPairMessage';
import { ClosedGroupNameChangeMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNameChangeMessage'; import { ClosedGroupNameChangeMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNameChangeMessage';
import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage'; import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage';
import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupRemovedMembersMessage'; import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupRemovedMembersMessage';
import { getSwarmPollingInstance } from '../apis/snode_api'; import { UserUtils } from '../utils';
import { ConversationAttributes, ConversationTypeEnum } from '../../models/conversationAttributes'; import { fromHexToArray, toHex } from '../utils/String';
import { GetNetworkTime } from '../apis/snode_api/getNetworkTime';
import { SnodeNamespaces } from '../apis/snode_api/namespaces';
export type GroupInfo = { export type GroupInfo = {
id: string; id: string;
@ -258,66 +254,6 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) {
); );
} }
export async function leaveClosedGroup(groupId: string) {
const convo = getConversationController().get(groupId);
if (!convo || !convo.isClosedGroup()) {
window?.log?.error('Cannot leave non-existing group');
return;
}
const ourNumber = UserUtils.getOurPubKeyFromCache();
const isCurrentUserAdmin = convo.get('groupAdmins')?.includes(ourNumber.key);
let members: Array<string> = [];
let admins: Array<string> = [];
// if we are the admin, the group must be destroyed for every members
if (isCurrentUserAdmin) {
window?.log?.info('Admin left a closed group. We need to destroy it');
convo.set({ left: true });
members = [];
admins = [];
} else {
// otherwise, just the exclude ourself from the members and trigger an update with this
convo.set({ left: true });
members = (convo.get('members') || []).filter((m: string) => m !== ourNumber.key);
admins = convo.get('groupAdmins') || [];
}
convo.set({ members });
await convo.updateGroupAdmins(admins, false);
await convo.commit();
const source = UserUtils.getOurPubKeyStrFromCache();
const networkTimestamp = GetNetworkTime.getNowWithNetworkOffset();
const dbMessage = await convo.addSingleOutgoingMessage({
group_update: { left: [source] },
sent_at: networkTimestamp,
expireTimer: 0,
});
// Send the update to the group
const ourLeavingMessage = new ClosedGroupMemberLeftMessage({
timestamp: networkTimestamp,
groupId,
identifier: dbMessage.id as string,
});
window?.log?.info(`We are leaving the group ${groupId}. Sending our leaving message.`);
// sent the message to the group and once done, remove everything related to this group
getSwarmPollingInstance().removePubkey(groupId);
await getMessageQueue().sendToGroup({
message: ourLeavingMessage,
namespace: SnodeNamespaces.ClosedGroupMessage,
sentCb: async () => {
window?.log?.info(
`Leaving message sent ${groupId}. Removing everything related to this group.`
);
await markGroupAsLeftOrKicked(groupId, convo, false);
},
});
}
async function sendNewName(convo: ConversationModel, name: string, messageId: string) { async function sendNewName(convo: ConversationModel, name: string, messageId: string) {
if (name.length === 0) { if (name.length === 0) {
window?.log?.warn('No name given for group update. Skipping'); window?.log?.warn('No name given for group update. Skipping');

@ -231,7 +231,11 @@ export class MessageQueue {
pubkey, pubkey,
}: { }: {
pubkey: PubKey; pubkey: PubKey;
message: ClosedGroupNewMessage | CallMessage | SharedConfigMessage; message:
| ClosedGroupNewMessage
| CallMessage
| SharedConfigMessage
| ClosedGroupMemberLeftMessage;
namespace: SnodeNamespaces; namespace: SnodeNamespaces;
}): Promise<boolean | number> { }): Promise<boolean | number> {
let rawMessage; let rawMessage;

@ -10,6 +10,7 @@ import {
import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface'; import { UserGroupsWrapperActions } from '../../../webworker/workers/browser/libsession_worker_interface';
import { OpenGroupUtils } from '../../apis/open_group_api/utils'; import { OpenGroupUtils } from '../../apis/open_group_api/utils';
import { getConversationController } from '../../conversations'; import { getConversationController } from '../../conversations';
import { isEmpty } from 'lodash';
/** /**
* The key of this map is the convoId as stored in the database. * The key of this map is the convoId as stored in the database.
@ -129,9 +130,18 @@ async function insertGroupsFromDBIntoWrapperAndRefresh(convoId: string): Promise
try { try {
window.log.debug(`inserting into usergroup wrapper "${foundConvo.id}"... }`); window.log.debug(`inserting into usergroup wrapper "${foundConvo.id}"... }`);
// this does the create or the update of the matching existing legacy group // this does the create or the update of the matching existing legacy group
await UserGroupsWrapperActions.setLegacyGroup(wrapperLegacyGroup); if (
!isEmpty(wrapperLegacyGroup.name) &&
!isEmpty(wrapperLegacyGroup.encPubkey) &&
!isEmpty(wrapperLegacyGroup.encSeckey)
) {
console.warn('inserting into user wrapper', wrapperLegacyGroup);
await UserGroupsWrapperActions.setLegacyGroup(wrapperLegacyGroup);
}
await refreshCachedUserGroup(convoId); await refreshCachedUserGroup(convoId);
} catch (e) { } catch (e) {
window.log.warn(`UserGroupsWrapperActions.set of ${convoId} failed with ${e.message}`); window.log.warn(`UserGroupsWrapperActions.set of ${convoId} failed with ${e.message}`);

@ -377,7 +377,7 @@ export const fetchTopMessagesForConversation = createAsyncThunk(
const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey); const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey);
if (!oldestMessage || oldestMessage.id === oldTopMessageId) { if (!oldestMessage || oldestMessage.id === oldTopMessageId) {
window.log.debug('fetchTopMessagesForConversation: we are already at the top'); // window.log.debug('fetchTopMessagesForConversation: we are already at the top');
return null; return null;
} }
const messagesProps = await getMessages({ const messagesProps = await getMessages({
@ -414,7 +414,7 @@ export const fetchBottomMessagesForConversation = createAsyncThunk(
const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey); const mostRecentMessage = await Data.getLastMessageInConversation(conversationKey);
if (!mostRecentMessage || mostRecentMessage.id === oldBottomMessageId) { if (!mostRecentMessage || mostRecentMessage.id === oldBottomMessageId) {
window.log.debug('fetchBottomMessagesForConversation: we are already at the bottom'); // window.log.debug('fetchBottomMessagesForConversation: we are already at the bottom');
return null; return null;
} }
const messagesProps = await getMessages({ const messagesProps = await getMessages({

@ -2,7 +2,7 @@
// tslint:disable-next-line: no-submodule-imports // tslint:disable-next-line: no-submodule-imports
import { compose } from 'lodash/fp'; import { compose } from 'lodash/fp';
import { escapeRegExp, isRegExp, isString } from 'lodash'; import { escapeRegExp, isNil, isRegExp, isString } from 'lodash';
import { getAppRootPath } from '../node/getRootPath'; import { getAppRootPath } from '../node/getRootPath';
const APP_ROOT_PATH = getAppRootPath(); const APP_ROOT_PATH = getAppRootPath();
@ -98,9 +98,16 @@ const removeNewlines = (text: string) => text.replace(/\r?\n|\r/g, '');
// redactSensitivePaths :: String -> String // redactSensitivePaths :: String -> String
const redactSensitivePaths = redactPath(APP_ROOT_PATH); const redactSensitivePaths = redactPath(APP_ROOT_PATH);
const isDev = (process.env.NODE_APP_INSTANCE || '').startsWith('devprod'); function shouldNotRedactLogs() {
// if the env variable `SESSION_NO_REDACT` is set, trust it as a boolean
if (!isNil(process.env.SESSION_NO_REDACT)) {
return process.env.SESSION_NO_REDACT;
}
// otherwise we don't want to redact logs when running on the devprod env
return (process.env.NODE_APP_INSTANCE || '').startsWith('devprod');
}
// redactAll :: String -> String // redactAll :: String -> String
export const redactAll = !isDev export const redactAll = !shouldNotRedactLogs()
? compose(redactSensitivePaths, redactGroupIds, redactSessionID, redactSnodeIP, redactServerUrl) ? compose(redactSensitivePaths, redactGroupIds, redactSessionID, redactSnodeIP, redactServerUrl)
: (text: string) => text; : (text: string) => text;

Loading…
Cancel
Save