From 646d2d8f543519684ef07867d94f034055e3b97d Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 18 Feb 2025 13:30:12 +1100 Subject: [PATCH 1/3] feat: mark 03 group expired if no hash retrieve at all --- ts/components/NoticeBanner.tsx | 11 +++- .../conversation/SessionConversation.tsx | 16 ++++++ .../overlay/OverlayRightPanelSettings.tsx | 3 + .../DeleteGroupMenuItem.tsx | 4 ++ .../menu/items/LeaveAndDeleteGroup/guard.ts | 3 +- ts/models/conversation.ts | 8 +++ ts/models/conversationAttributes.ts | 7 +++ ts/node/database_utility.ts | 2 + ts/node/migration/sessionMigrations.ts | 19 +++++++ ts/node/sql.ts | 2 + ts/react.d.ts | 1 + .../apis/snode_api/SnodeRequestTypes.ts | 55 ++++++++++++++++++- .../snode_api/signature/groupSignature.ts | 4 +- ts/session/apis/snode_api/swarmPolling.ts | 15 +++++ ts/session/sending/MessageSender.ts | 2 + .../utils/job_runners/jobs/GroupSyncJob.ts | 6 +- ts/state/ducks/conversations.ts | 1 + ts/state/selectors/selectedConversation.ts | 7 +++ 18 files changed, 158 insertions(+), 8 deletions(-) diff --git a/ts/components/NoticeBanner.tsx b/ts/components/NoticeBanner.tsx index aa27c9c9b..19111a0b5 100644 --- a/ts/components/NoticeBanner.tsx +++ b/ts/components/NoticeBanner.tsx @@ -25,14 +25,16 @@ const StyledText = styled.span` type NoticeBannerProps = { text: string; - icon: SessionIconType; - onBannerClick: () => void; + icon?: SessionIconType; + onBannerClick?: () => void; dataTestId: SessionDataTestId; }; export const NoticeBanner = (props: NoticeBannerProps) => { const { text, onBannerClick, icon, dataTestId } = props; + const canBeClicked = !!onBannerClick; + return ( { alignItems={'center'} data-testid={dataTestId} onClick={event => { + if (!canBeClicked) { + return; + } event?.preventDefault(); onBannerClick(); }} > {text} - + {icon ? : null} ); }; diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index bf9ce0cc6..d353352aa 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -63,6 +63,7 @@ import { PubKey } from '../../session/types'; import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { localize } from '../../localization/localeTools'; import { + useConversationIsExpired03Group, useSelectedConversationKey, useSelectedIsPrivate, useSelectedIsPublic, @@ -110,6 +111,20 @@ const ConvoLoadingSpinner = () => { ); }; +const GroupMarkedAsExpired = () => { + const selectedConvo = useSelectedConversationKey(); + const isExpired03Group = useConversationIsExpired03Group(selectedConvo); + if (!selectedConvo || !PubKey.is03Pubkey(selectedConvo) || !isExpired03Group) { + return null; + } + return ( + + ); +}; + export class SessionConversation extends Component { private readonly messageContainerRef: RefObject; private dragCounter: number; @@ -259,6 +274,7 @@ export class SessionConversation extends Component { <>
+ { const selectedUsername = useConversationUsername(convoId) || convoId; const isPublic = useIsPublic(convoId); const isGroupDestroyed = useIsGroupDestroyed(convoId); + const is03GroupExpired = useConversationIsExpired03Group(convoId); const showItem = showDeleteGroupItem({ isGroup, @@ -242,6 +244,7 @@ const DeleteGroupPanelButton = () => { isMessageRequestShown, isPublic, isGroupDestroyed, + is03GroupExpired, }); if (!showItem || !convoId) { diff --git a/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx b/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx index 415b14e56..9c905d0ef 100644 --- a/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx +++ b/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx @@ -13,6 +13,7 @@ import { ItemWithDataTestId } from '../MenuItemWithDataTestId'; import { showDeleteGroupItem } from './guard'; import { Localizer } from '../../../basic/Localizer'; import { useDisableLegacyGroupDeprecatedActions } from '../../../../hooks/useRefreshReleasedFeaturesTimestamp'; +import { useConversationIsExpired03Group } from '../../../../state/selectors/selectedConversation'; export const DeleteGroupMenuItem = () => { const convoId = useConvoIdFromContext(); @@ -23,12 +24,15 @@ export const DeleteGroupMenuItem = () => { const isPublic = useIsPublic(convoId); const isGroupDestroyed = useIsGroupDestroyed(convoId); + const is03GroupExpired = useConversationIsExpired03Group(convoId); + const showLeave = showDeleteGroupItem({ isGroup, isKickedFromGroup, isMessageRequestShown, isPublic, isGroupDestroyed, + is03GroupExpired, }); if (!showLeave) { diff --git a/ts/components/menu/items/LeaveAndDeleteGroup/guard.ts b/ts/components/menu/items/LeaveAndDeleteGroup/guard.ts index baafe0d67..c84af9a96 100644 --- a/ts/components/menu/items/LeaveAndDeleteGroup/guard.ts +++ b/ts/components/menu/items/LeaveAndDeleteGroup/guard.ts @@ -48,6 +48,7 @@ export function showDeleteGroupItem(args: { isMessageRequestShown: boolean; isKickedFromGroup: boolean; isGroupDestroyed: boolean; + is03GroupExpired: boolean; }) { - return sharedEnabled(args) && !showLeaveGroupItem(args); + return (sharedEnabled(args) && !showLeaveGroupItem(args)) || args.is03GroupExpired; } diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 8c1fa5840..81016d8f8 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -381,6 +381,10 @@ export class ConversationModel extends Backbone.Model { toRet.hasOutdatedClient = this.getHasOutdatedClient(); } + if (this.isExpired03Group()) { + toRet.isExpired03Group = true; + } + if ( currentNotificationSetting && currentNotificationSetting !== ConversationNotificationSetting[0] @@ -2707,6 +2711,10 @@ export class ConversationModel extends Backbone.Model { return this.get('hasOutdatedClient'); } + public isExpired03Group() { + return PubKey.is03Pubkey(this.id) && !!this.get('isExpired03Group'); + } + // #endregion } diff --git a/ts/models/conversationAttributes.ts b/ts/models/conversationAttributes.ts index a96666303..8105a3f3e 100644 --- a/ts/models/conversationAttributes.ts +++ b/ts/models/conversationAttributes.ts @@ -101,6 +101,13 @@ export interface ConversationAttributes { // TODO we need to make a migration to remove this value from the db since the implementation is hacky /** to warn the user that the person he is talking to is using an old client which might cause issues */ hasOutdatedClient?: string; + + /** + * An 03-group is expired if an admin didn't come online for the last 30 days. + * In that case, we might not have any keys on the swarm, and so restoring from seed would mean we can't actually + * send any messages/nor decrypt any. + */ + isExpired03Group?: boolean; } /** diff --git a/ts/node/database_utility.ts b/ts/node/database_utility.ts index 329394094..73287134a 100644 --- a/ts/node/database_utility.ts +++ b/ts/node/database_utility.ts @@ -79,6 +79,7 @@ const allowedKeysFormatRowOfConversation = [ 'priority', 'expirationMode', 'hasOutdatedClient', + 'isExpired03Group', ]; export function formatRowOfConversation( @@ -213,6 +214,7 @@ const allowedKeysOfConversationAttributes = [ 'priority', 'expirationMode', 'hasOutdatedClient', + 'isExpired03Group', ]; /** diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 76ad85aaf..b3284fb68 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -109,6 +109,7 @@ const LOKI_SCHEMA_VERSIONS = [ updateToSessionSchemaVersion38, updateToSessionSchemaVersion39, updateToSessionSchemaVersion40, + updateToSessionSchemaVersion41, ]; function updateToSessionSchemaVersion1(currentVersion: number, db: BetterSqlite3.Database) { @@ -2047,6 +2048,24 @@ function updateToSessionSchemaVersion40(currentVersion: number, db: BetterSqlite console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); } +function updateToSessionSchemaVersion41(currentVersion: number, db: BetterSqlite3.Database) { + const targetVersion = 41; + if (currentVersion >= targetVersion) { + return; + } + + console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`); + + db.transaction(() => { + // the 'isExpired03Group' field is used to keep track of an 03 group is expired + db.prepare(`ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN isExpired03Group BOOLEAN;`).run(); + + 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/sql.ts b/ts/node/sql.ts index 0c59cee8b..5612687df 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -474,6 +474,7 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn expirationMode, expireTimer, hasOutdatedClient, + isExpired03Group, lastMessage, lastMessageStatus, lastMessageInteractionType, @@ -527,6 +528,7 @@ function saveConversation(data: ConversationAttributes): SaveConversationReturn expirationMode, expireTimer, hasOutdatedClient, + isExpired03Group, lastMessageStatus, lastMessage: shortenedLastMessage, lastMessageInteractionType, diff --git a/ts/react.d.ts b/ts/react.d.ts index 73bf5f1f1..5ebfcde11 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -246,6 +246,7 @@ declare module 'react' { | 'modal-heading' | 'modal-description' | 'error-message' + | 'group-not-updated-30-days-banner' // modules profile name | 'module-conversation__user__profile-name' | 'module-message-search-result__header__name__profile-name' diff --git a/ts/session/apis/snode_api/SnodeRequestTypes.ts b/ts/session/apis/snode_api/SnodeRequestTypes.ts index ab0e86c19..cf172bf50 100644 --- a/ts/session/apis/snode_api/SnodeRequestTypes.ts +++ b/ts/session/apis/snode_api/SnodeRequestTypes.ts @@ -29,6 +29,7 @@ import { WithTimestamp, WithGetNow, } from '../../types/with'; +import { isDevProd } from '../../../shared/env_vars'; /** * This is the base sub request class that every other type of request has to extend. @@ -636,6 +637,57 @@ export class DeleteAllFromGroupMsgNodeSubRequest extends DeleteAllSubRequest { } } +/** + * Delete all the normal and config messages from a group swarm. + * Note: only used for debugging purposes + */ +export class DeleteAllFromGroupNodeSubRequest extends DeleteAllSubRequest { + public readonly namespace = 'all'; + public readonly adminSecretKey: Uint8Array; + public readonly destination: GroupPubkeyType; + + constructor(args: WithGroupPubkey & WithSecretKey) { + super(); + this.destination = args.groupPk; + this.adminSecretKey = args.secretKey; + if (isEmpty(this.adminSecretKey)) { + throw new Error('DeleteAllFromGroupMsgNodeSubRequest needs an adminSecretKey'); + } + if (!isDevProd()) { + throw new Error('DeleteAllFromGroupNodeSubRequest can only be used on non-production build'); + } + } + + public async build() { + const signDetails = await SnodeGroupSignature.getSnodeGroupSignature({ + method: this.method, + namespace: this.namespace, + group: { authData: null, pubkeyHex: this.destination, secretKey: this.adminSecretKey }, + }); + + if (!signDetails) { + throw new Error( + `[DeleteAllFromGroupMsgNodeSubRequest] SnodeSignature.getSnodeSignatureParamsUs returned an empty result` + ); + } + return { + method: this.method, + params: { + ...signDetails, + namespace: this.namespace, + }, + }; + } + + public loggingId(): string { + return `${this.method}-${ed25519Str(this.destination)}-${this.namespace}`; + } + + public getDestination() { + return this.destination; + } +} + export class DeleteHashesFromUserNodeSubRequest extends DeleteSubRequest { public readonly messageHashes: Array; public readonly destination: PubkeyType; @@ -1336,7 +1388,8 @@ export type RawSnodeSubRequests = | SubaccountRevokeSubRequest | SubaccountUnrevokeSubRequest | GetExpiriesFromNodeSubRequest - | DeleteAllFromGroupMsgNodeSubRequest; + | DeleteAllFromGroupMsgNodeSubRequest + | DeleteAllFromGroupNodeSubRequest; export type BuiltSnodeSubRequests = AwaitedReturn; diff --git a/ts/session/apis/snode_api/signature/groupSignature.ts b/ts/session/apis/snode_api/signature/groupSignature.ts index d55fc3a45..ce7bdf803 100644 --- a/ts/session/apis/snode_api/signature/groupSignature.ts +++ b/ts/session/apis/snode_api/signature/groupSignature.ts @@ -94,7 +94,7 @@ async function getGroupPromoteMessage({ type ParamsShared = { groupPk: GroupPubkeyType; - namespace: SnodeNamespacesGroup; + namespace: SnodeNamespacesGroup | 'all'; method: 'retrieve' | 'store' | 'delete_all'; }; @@ -150,7 +150,7 @@ export type GroupDetailsNeededForSignature = Pick< type StoreOrRetrieve = { method: 'store' | 'retrieve'; namespace: SnodeNamespacesGroup }; type DeleteHashes = { method: 'delete'; hashes: Array }; -type DeleteAllNonConfigs = { method: 'delete_all'; namespace: SnodeNamespacesGroup }; +type DeleteAllNonConfigs = { method: 'delete_all'; namespace: SnodeNamespacesGroup | 'all' }; async function getSnodeGroupSignature({ group, diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 45c1ced91..4830d4260 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -703,7 +703,22 @@ export class SwarmPolling { ); return []; } + const noConfigBeforeFetch = namespacesAndLastHashes.some( + m => !m.lastHash && SnodeNamespace.isGroupConfigNamespace(m.namespace) + ); + + const noConfigAfterFetch = namespacesAndLastHashesAfterFetch.some( + m => !m.lastHash && SnodeNamespace.isGroupConfigNamespace(m.namespace) + ); + if (PubKey.is03Pubkey(pubkey) && noConfigBeforeFetch && noConfigAfterFetch) { + window.log.warn(`no configs before and after fetch of group: ${ed25519Str(pubkey)}`); + const convo = ConvoHub.use().get(pubkey); + if (convo && !convo.get('isExpired03Group')) { + convo.set({ isExpired03Group: true }); + await convo.commit(); + } + } if (!results.length) { return []; } diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index c91043586..9fb0b893b 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -29,6 +29,7 @@ import { StoreUserMessageSubRequest, SubaccountRevokeSubRequest, SubaccountUnrevokeSubRequest, + type DeleteAllFromGroupNodeSubRequest, } from '../apis/snode_api/SnodeRequestTypes'; import { NotEmptyArrayOfBatchResults } from '../apis/snode_api/BatchResultEntry'; import { BatchRequests } from '../apis/snode_api/batchRequest'; @@ -426,6 +427,7 @@ type SortedSubRequestsType = Array< | StoreRequestPerPubkey | DeleteHashesRequestPerPubkey | DeleteAllFromGroupMsgNodeSubRequest + | DeleteAllFromGroupNodeSubRequest | SubaccountRevokeSubRequest | SubaccountUnrevokeSubRequest >; diff --git a/ts/session/utils/job_runners/jobs/GroupSyncJob.ts b/ts/session/utils/job_runners/jobs/GroupSyncJob.ts index 8cd5633ce..6e3cc7663 100644 --- a/ts/session/utils/job_runners/jobs/GroupSyncJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupSyncJob.ts @@ -12,6 +12,7 @@ import { } from '../../../../webworker/workers/browser/libsession_worker_interface'; import { DeleteAllFromGroupMsgNodeSubRequest, + DeleteAllFromGroupNodeSubRequest, DeleteHashesFromGroupNodeSubRequest, MAX_SUBREQUESTS_COUNT, StoreGroupKeysSubRequest, @@ -116,7 +117,9 @@ async function pushChangesToGroupSwarmIfNeeded({ WithRevokeSubRequest & Partial & { supplementalKeysSubRequest?: StoreGroupKeysSubRequest; - deleteAllMessagesSubRequest?: DeleteAllFromGroupMsgNodeSubRequest; + deleteAllMessagesSubRequest?: + | DeleteAllFromGroupMsgNodeSubRequest + | DeleteAllFromGroupNodeSubRequest; extraStoreRequests: Array; }): Promise { // save the dumps to DB even before trying to push them, so at least we have an up to date dumps in the DB in case of crash, no network etc @@ -173,6 +176,7 @@ async function pushChangesToGroupSwarmIfNeeded({ m instanceof SubaccountRevokeSubRequest || m instanceof SubaccountUnrevokeSubRequest || m instanceof DeleteAllFromGroupMsgNodeSubRequest || + m instanceof DeleteAllFromGroupNodeSubRequest || m instanceof DeleteHashesFromGroupNodeSubRequest ); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index b6e320fca..99f9a85aa 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -212,6 +212,7 @@ export interface ReduxConversationType { expirationMode?: DisappearingMessageConversationModeType; expireTimer?: number; hasOutdatedClient?: string; + isExpired03Group?: boolean; isTyping?: boolean; isBlocked?: boolean; isKickedFromGroup?: boolean; diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index f8f0b1749..9fd570c5f 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -436,3 +436,10 @@ export function useSelectedLastMessage() { export function useSelectedMessageIds() { return useSelector(getSelectedMessageIds); } + +export function useConversationIsExpired03Group(convoId?: string) { + return useSelector( + (state: StateType) => + !!convoId && PubKey.is03Pubkey(convoId) && !!getSelectedConversation(state)?.isExpired03Group + ); +} From 5bebd3f0291643cc7254717268dc1c3d356b69aa Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 18 Feb 2025 14:47:39 +1100 Subject: [PATCH 2/3] fix: show first 2 char for ed25519 key so we know if that's a group or priv chat --- ts/components/NoticeBanner.tsx | 4 +--- ts/models/conversation.ts | 4 ++-- ts/session/utils/String.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ts/components/NoticeBanner.tsx b/ts/components/NoticeBanner.tsx index 19111a0b5..d21ca90be 100644 --- a/ts/components/NoticeBanner.tsx +++ b/ts/components/NoticeBanner.tsx @@ -33,8 +33,6 @@ type NoticeBannerProps = { export const NoticeBanner = (props: NoticeBannerProps) => { const { text, onBannerClick, icon, dataTestId } = props; - const canBeClicked = !!onBannerClick; - return ( { alignItems={'center'} data-testid={dataTestId} onClick={event => { - if (!canBeClicked) { + if (!onBannerClick) { return; } event?.preventDefault(); diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 81016d8f8..bc85200a8 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -381,7 +381,7 @@ export class ConversationModel extends Backbone.Model { toRet.hasOutdatedClient = this.getHasOutdatedClient(); } - if (this.isExpired03Group()) { + if (this.getIsExpired03Group()) { toRet.isExpired03Group = true; } @@ -2711,7 +2711,7 @@ export class ConversationModel extends Backbone.Model { return this.get('hasOutdatedClient'); } - public isExpired03Group() { + public getIsExpired03Group() { return PubKey.is03Pubkey(this.id) && !!this.get('isExpired03Group'); } diff --git a/ts/session/utils/String.ts b/ts/session/utils/String.ts index dd2cc3928..ce384c3ff 100644 --- a/ts/session/utils/String.ts +++ b/ts/session/utils/String.ts @@ -63,5 +63,9 @@ export const sanitizeSessionUsername = (inputName: string) => { return validChars; }; -export const ed25519Str = (ed25519Key: string) => - `(...${ed25519Key.length > 58 ? ed25519Key.substr(58) : ed25519Key})`; +export const ed25519Str = (ed25519Key: string) => { + if (ed25519Key.length <= 6) { + return `(${ed25519Key})`; + } + return `(${ed25519Key.substr(0, 2)}...${ed25519Key.substr(-4)})`; +}; From 374a0f9272d27deed544bd192728f16c367172dd Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 18 Feb 2025 15:01:45 +1100 Subject: [PATCH 3/3] fix: rollback changes to ed25519str --- ts/session/utils/String.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ts/session/utils/String.ts b/ts/session/utils/String.ts index ce384c3ff..dd2cc3928 100644 --- a/ts/session/utils/String.ts +++ b/ts/session/utils/String.ts @@ -63,9 +63,5 @@ export const sanitizeSessionUsername = (inputName: string) => { return validChars; }; -export const ed25519Str = (ed25519Key: string) => { - if (ed25519Key.length <= 6) { - return `(${ed25519Key})`; - } - return `(${ed25519Key.substr(0, 2)}...${ed25519Key.substr(-4)})`; -}; +export const ed25519Str = (ed25519Key: string) => + `(...${ed25519Key.length > 58 ? ed25519Key.substr(58) : ed25519Key})`;