From 3ccd5de96caa3efe3b43a4bb62bcd416d62ed70e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 15 May 2026 13:55:29 +0400 Subject: [PATCH] wip --- .../Chat/Group/GroupMemberInfoView.swift | 3 + .../Shared/Views/NewChat/AddChannelView.swift | 10 +- apps/ios/SimpleXChat/ChatTypes.swift | 2 + apps/ios/product/concepts.md | 2 +- apps/ios/product/views/group-info.md | 1 + apps/ios/spec/api.md | 1 + apps/ios/spec/client/chat-view.md | 6 +- apps/ios/spec/impact.md | 2 +- apps/ios/spec/state.md | 4 +- docs/protocol/channels-protocol.md | 10 + simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 3 + src/Simplex/Chat/Library/Commands.hs | 9 + src/Simplex/Chat/Library/Internal.hs | 26 ++ src/Simplex/Chat/Library/Subscriber.hs | 62 ++++- src/Simplex/Chat/Protocol.hs | 7 + src/Simplex/Chat/Store/Groups.hs | 52 +++- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- ...20260514_relay_request_group_link_index.hs | 21 ++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- ...20260514_relay_request_group_link_index.hs | 20 ++ .../Store/SQLite/Migrations/chat_schema.sql | 6 + src/Simplex/Chat/Types.hs | 24 ++ src/Simplex/Chat/Types/Shared.hs | 13 + src/Simplex/Chat/View.hs | 13 +- tests/ChatTests/Groups.hs | 222 ++++++++++++++++++ 26 files changed, 499 insertions(+), 30 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 883a768d97..b33b329732 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -199,6 +199,9 @@ struct GroupMemberInfoView: View { Label("Share relay address", systemImage: "square.and.arrow.up") } } + if groupRelay?.relayStatus == .rsRejected { + infoRow("Status", "rejected by relay operator") + } } header: { Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary) } footer: { diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 32d6e7fe2c..b5fcb7b36d 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -486,8 +486,14 @@ func chatRelayDisplayName(_ relay: UserChatRelay) -> String { func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View { let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false - let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow) - let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text + let isRejected = status == .rsRejected + let color: Color = connFailed || removed || isRejected ? .red : (status == .rsActive ? .green : .yellow) + let text: LocalizedStringKey = + connFailed ? "failed" + : memberStatus == .memLeft ? "removed by operator" + : isRejected ? "rejected" + : removed ? "removed" + : status.text return HStack(spacing: 4) { Circle() .fill(color) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1dfa477c91..a0efe396fc 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2640,6 +2640,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable { case rsAccepted = "accepted" case rsActive = "active" case rsInactive = "inactive" + case rsRejected = "rejected" } public struct RelayProfile: Codable, Equatable, Hashable { @@ -2713,6 +2714,7 @@ extension RelayStatus { case .rsAccepted: "accepted" case .rsActive: "active" case .rsInactive: "inactive" + case .rsRejected: "rejected" } } } diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 3fa722d47a..6d63ee2faf 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | | 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | | 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | -| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | --- diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index ee0c449c68..d967f064a9 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -221,6 +221,7 @@ Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection stat | "Unblock for all?" alert | "Unblock subscriber for all?" | | Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | | Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". Indicates the relay refused to rejoin this channel after a prior `/leave` — clearable only by the relay operator via `/relay allow `. | | Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | ## Related Specs diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 45a06c371f..855c22139a 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI. | `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) | | `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) | | `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) | +| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/relay allow ` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) | ### File Transfer Events diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index 182e7b7ce9..a968aee3af 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -350,9 +350,13 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha ### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) Navigates to relay list view with role-based branches: -- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). +- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering. - **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). +### Relay Refusal Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` becomes `.memRejected`. The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/relay allow `, which has no owner-facing event. + ### Leave Button Logic Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`. diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index eaf646e7f4..74acec789e 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -61,7 +61,7 @@ | Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list | | Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | | Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | -| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members | | Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels | | Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | | Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 6dda4ba275..db16aa2936 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support | Type | Kind | Description | Line | |------|------|-------------|------| -| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | -| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | | `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | | `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index b6b9b3ee5b..290698528e 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -72,6 +72,16 @@ When the owner adds a relay to an existing channel: The announce is an optimisation. When it does not reach a subscriber — because the channel had no subscribers at announce time, because an older client or relay sits in the path, or because of a transient network failure — the subscriber reaches the same end state on the next channel open via its relay sync against the channel's link data. +### Relay refusal + +**Trigger.** When a relay operator runs `APILeaveGroup` (`/leave #channel`) on a channel the relay serves, the relay's local `groups` row for the channel transitions `relay_own_status → 'rejected'` in the same transaction that flips its membership to `GSMemLeft`. The row is keyed by the channel's `relay_request_group_link` (a `ShortLinkContact`), which the relay learned when it received the original `x.grp.relay.inv`. + +**Signal.** On the next `x.grp.relay.inv` from any owner for a channel link the relay has already marked `'rejected'`, the relay accepts the contact through the agent's normal async-accept path and sends `x.grp.relay.reject` over the owner-relay direct contact channel, then lets the eventual INFO event drive cleanup of its transient bookkeeping. The message is unsigned and is not part of the forwarded group message set. The payload carries a `RelayRejectionReason` — currently only `rejoin_refused`; older relays or future reasons fall through to `RRRUnknown text` for forward compatibility. + +**Owner handling.** The owner's CONF handler validates that the sender is a relay member (`memberRole == GRRelay`) and that the receiver is the owner. It transitions the corresponding `GroupRelay.relayStatus` atomically `RSInvited → RSRejected`, marks the relay `GroupMember` `GSMemRejected`, and deletes the chat-layer connection. The transition is final on the owner side and is only cleared by the relay operator running `/relay allow ` (which transitions the relay's own row `'rejected' → 'inactive'` and emits no event back to the owner). The owner's subsequent user-initiated `addRelays` invocation creates a fresh `GroupRelay` row independent of the rejected one and proceeds normally — the relay's lookup will find no `'rejected'` row for the link. + +**Limitations.** (a) An older owner client that does not recognise `x.grp.relay.reject` parses it as `XUnknown` and falls through to the CONF catch-all, logging a parse error and leaving the relay's `GroupRelay` permanently at `RSInvited` — the same UX as a relay that never responds. (b) An older relay binary continues to write `RSInactive` on leave and does not enforce refusal at `xGrpRelayInv`. In a mixed-version deployment where some relays are old, those relays accept fresh invitations after a leave while new-binary relays refuse — asymmetric behaviour that the operator can resolve by re-running `/leave` under the new binary. + ### Subscriber connection A subscriber joins a channel through the following flow: diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1e463dfe48..0fa7ef33ef 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -132,6 +132,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index else exposed-modules: Simplex.Chat.Archive @@ -286,6 +287,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 84bebb3de6..01465d0e5b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -407,6 +407,7 @@ data ChatCommand | SetUserChatRelays [CLINewRelay] | APITestChatRelay UserId ShortLinkContact | TestChatRelay ShortLinkContact + | APIAllowRelayGroup {groupId :: GroupId} | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) @@ -735,6 +736,7 @@ data ChatResponse | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]} + | CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} | CRGroupRelaysAdded {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRGroupRelaysAddFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupMembers {user :: User, group :: Group} @@ -945,6 +947,7 @@ data ChatEvent data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERelayRejected {user :: User, groupInfo :: GroupInfo, relayRejectionReason :: RelayRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | TEContactVerificationReset {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9438636587..3a0ee024b6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1244,6 +1244,8 @@ processChatCommand vr nm = \case let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner + when (relayOwnStatus gInfo == Just RSRejected) $ + throwChatError $ CECommandError "cannot delete a rejected channel; run /_relay allow first" filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId $ do deleteCIFiles user filesInfo @@ -1578,6 +1580,9 @@ processChatCommand vr nm = \case Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) TestChatRelay address -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestChatRelay userId address + APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo' <- withStore $ \db -> allowRelayGroupAndSiblings db vr user groupId + pure $ CRRelayGroupAllowed user gInfo' GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) @@ -2932,6 +2937,8 @@ processChatCommand vr nm = \case deleteGroupLinkIfExists user gInfo' -- member records are not deleted to keep history withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft + when (useRelays' gInfo && isRelay membership) $ + withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSRejected pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} where -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. @@ -5032,6 +5039,8 @@ chatCommandP = "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP), "/relay test " *> (TestChatRelay <$> strP), + "/_relay allow " *> (APIAllowRelayGroup <$> A.decimal), + "/relay allow " *> (APIAllowRelayGroup <$> A.decimal), "/relays " *> (SetUserChatRelays <$> chatRelaysP), "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8324107a11..35ab65fdaa 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1059,6 +1059,32 @@ acceptRelayJoinRequestAsync ownerMember' <- getGroupMemberById db vr user groupMemberId pure (gInfo', ownerMember') +-- Asynchronous rejection of a relay invitation. Mirrors acceptGroupJoinSendRejectAsync: +-- creates a transient groups row tagged RSRejected (so the worker ignores it) with the +-- owner member in GSMemInvited so the eventual INFO arrival drives cleanup, then enqueues +-- agentAcceptContactAsync to send XGrpRelayReject through the agent's normal retry path. +acceptRelayJoinRequestRejectAsync + :: User + -> Int64 + -> VersionRangeChat + -> GroupRelayInvitation + -> InvitationId + -> VersionRangeChat + -> Int64 + -> RelayRejectionReason + -> CM () +acceptRelayJoinRequestRejectAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do + (_gInfo, ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + let GroupMember {groupMemberId} = ownerMember + msg = XGrpRelayReject reason + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` reqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore' $ \db -> + createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 771964b031..94c397d691 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -770,6 +770,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setRelayLinkConfId db m confId relayLink void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" + XGrpRelayReject reason + | memberRole' membership == GROwner && isRelay m -> do + relay <- withStore $ \db -> do + relay <- getGroupRelayByGMId db (groupMemberId' m) + liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + -- complete the contact handshake so the relay receives INFO and cleans + -- up its transient bookkeeping (the new INFO arm at GCHostMember + + -- RSRejected handles that), then tear down our chat-layer connection. + allowAgentConnectionAsync user conn' confId XOk + toView $ CEvtGroupRelayUpdated user gInfo m relay + toViewTE $ TERelayRejected user gInfo reason + | otherwise -> messageError "x.grp.relay.reject: only owner can receive relay rejection" _ -> messageError "CONF from invited member must have x.grp.acpt" GCHostMember -> case chatMsgEvent of @@ -812,14 +824,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" -- sent when connecting via group link - XInfo _ -> - -- TODO Keep rejected member to allow them to appeal against rejection. - when (memberStatus m == GSMemRejected) $ do - deleteMemberConnection' m True - withStore' $ \db -> deleteGroupMember db user m - XOk -> pure () + XInfo _ + | memberStatus m == GSMemRejected -> do + -- TODO Keep rejected member to allow them to appeal against rejection. + deleteMemberConnection' m True + withStore' $ \db -> deleteGroupMember db user m + | cleanupTransientRelayReject -> cleanupRelayRejectRow + | otherwise -> pure () + XOk + | cleanupTransientRelayReject -> cleanupRelayRejectRow + | otherwise -> pure () _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () + where + -- Transient relay-reject row cleanup. The transient row is created with + -- RSRejected + GCHostMember owner. The persistent /leave-time row has the + -- same combination but its host connection is deleted by DJRelayRemoved + -- before any INFO can fire, so this filter only matches transients. + -- RSInactive is also matched to handle the case where APIAllowRelayGroup + -- flipped the transient row mid-flight. + cleanupTransientRelayReject = + memberCategory m == GCHostMember + && maybe False (`elem` ([RSRejected, RSInactive] :: [RelayStatus])) (relayOwnStatus gInfo) + cleanupRelayRejectRow = do + deleteMemberConnection' m True + withStore' $ \db -> do + deleteGroupMember db user m + deleteGroup db user gInfo CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do -- TODO [knocking] send pending messages after accepting? -- possible improvement: check for each pending message, requires keeping track of connection state @@ -933,7 +964,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) then let tasks - | relayOwnStatus gInfo' == Just RSInactive = filter relayRemovedNewTask newDeliveryTasks + | relayNotServing (relayOwnStatus gInfo') = filter relayRemovedNewTask newDeliveryTasks | otherwise = newDeliveryTasks in createDeliveryTasks gInfo' m' tasks else pure False @@ -1522,10 +1553,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () - xGrpRelayInv invId chatVRange groupRelayInv = do + xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + refused <- withStore' $ \db -> isRelayGroupRefused db user groupLink initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config - (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay - lift $ void $ getRelayRequestWorker True + if refused + then acceptRelayJoinRequestRejectAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRefused + else do + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited + lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge = do privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) @@ -3129,7 +3165,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> do updateGroupMemberStatus db userId membership GSMemRemoved - when (isJust $ relayOwnStatus gInfo) $ updateRelayOwnStatus_ db gInfo RSInactive + when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem msg gInfo RGEUserDeleted @@ -3568,7 +3604,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do processDeliveryTask task@MessageDeliveryTask {jobScope} = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | relayNotServing (relayOwnStatus gInfo) -> do logWarn "delivery task worker: relay inactive" withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> @@ -3638,7 +3674,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do processDeliveryJob job = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | relayNotServing (relayOwnStatus gInfo) -> do logWarn "delivery job worker: relay inactive" withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) "relay inactive" | otherwise -> do diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 02efa7064c..a4edc4a4a3 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -444,6 +444,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -989,6 +990,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpRelayAcpt_ :: CMEventTag 'Json XGrpRelayTest_ :: CMEventTag 'Json XGrpRelayNew_ :: CMEventTag 'Json + XGrpRelayReject_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1047,6 +1049,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpRelayAcpt_ -> "x.grp.relay.acpt" XGrpRelayTest_ -> "x.grp.relay.test" XGrpRelayNew_ -> "x.grp.relay.new" + XGrpRelayReject_ -> "x.grp.relay.reject" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1106,6 +1109,7 @@ instance StrEncoding ACMEventTag where "x.grp.relay.acpt" -> XGrpRelayAcpt_ "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.relay.new" -> XGrpRelayNew_ + "x.grp.relay.reject" -> XGrpRelayReject_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1161,6 +1165,7 @@ toCMEventTag msg = case msg of XGrpRelayAcpt _ -> XGrpRelayAcpt_ XGrpRelayTest {} -> XGrpRelayTest_ XGrpRelayNew _ -> XGrpRelayNew_ + XGrpRelayReject _ -> XGrpRelayReject_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1319,6 +1324,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" pure $ XGrpRelayTest challenge sig_ XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink" + XGrpRelayReject_ -> XGrpRelayReject <$> p "reason" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1389,6 +1395,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en ("signature" .=? (B64UrlByteString <$> sig_)) ["challenge" .= B64UrlByteString challenge] XGrpRelayNew relayLink -> o ["relayLink" .= relayLink] + XGrpRelayReject reason -> o ["reason" .= reason] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4bb94ba2a8..2e0f2937d9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -95,6 +95,8 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + isRelayGroupRefused, + allowRelayGroupAndSiblings, getRelayServedGroups, getRelayInactiveGroups, createNewContactMemberAsync, @@ -1523,8 +1525,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay = do +createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay ownerStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1538,10 +1540,10 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) Nothing currentTs + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs -- Store relay request data for recovery liftIO $ setRelayRequestData_ groupId currentTs - ownerMemberId <- insertOwner_ currentTs groupId + ownerMemberId <- insertOwner_ currentTs groupId ownerStatus let relayMember = MemberIdRole relayMemberId GRRelay -- TODO [member keys] should relays use member keys? _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr @@ -1563,7 +1565,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe WHERE group_id = ? |] (Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, initialDelay, currentTs, groupId) - insertOwner_ currentTs groupId = do + insertOwner_ currentTs groupId ownerStatus_ = do let MemberIdRole {memberId, memberRole} = fromMember VersionRange minV maxV = reqChatVRange (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs @@ -1578,7 +1580,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, ownerStatus_) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -1596,6 +1598,44 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +-- Atomically flip every RSRejected row sharing the targeted group's +-- relay_request_group_link to RSInactive. Returns the refreshed GroupInfo +-- for the targeted groupId (whether it was flipped or not). The subquery +-- resolves the link in the same UPDATE statement so there is no +-- read-then-write race with concurrent xGrpRelayInv handlers. +allowRelayGroupAndSiblings :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo +allowRelayGroupAndSiblings db vr user@User {userId} groupId = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE groups + SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? + WHERE user_id = ? + AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?) + AND relay_own_status = ? + |] + (RSInactive, currentTs, currentTs, userId, groupId, RSRejected) + getGroupInfo db vr user groupId + +isRelayGroupRefused :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRefused db User {userId} groupLink = + fromMaybe False <$> maybeFirstRow fromOnly ( + DB.query + db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) + ) + getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] getRelayServedGroups db vr User {userId, userContactId} = do map (toGroupInfo vr userContactId []) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 822068a771..437f16a43c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -30,6 +30,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -59,7 +60,8 @@ schemaMigrations = ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), - ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at) + ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..217b56d2fa --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260514_relay_request_group_link_index :: Text +m20260514_relay_request_group_link_index = + [r| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Text +down_m20260514_relay_request_group_link_index = + [r| +DROP INDEX idx_groups_relay_request_group_link; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4ee3f44b07..9990ed74fd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -153,6 +153,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -305,7 +306,8 @@ schemaMigrations = ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), - ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at) + ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..ef2bc8ccd0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260514_relay_request_group_link_index :: Query +m20260514_relay_request_group_link_index = + [sql| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Query +down_m20260514_relay_request_group_link_index = + [sql| +DROP INDEX idx_groups_relay_request_group_link; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b7a6db437b..86c198670c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1295,6 +1295,12 @@ CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items( item_viewed, item_ts ); +CREATE INDEX idx_groups_relay_request_group_link +ON groups( + user_id, + relay_request_group_link +) +WHERE relay_request_group_link IS NOT NULL; CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b068e6b679..19a7747087 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -916,6 +916,30 @@ instance ToJSON GroupRejectionReason where toJSON = strToJSON toEncoding = strToJEncoding +data RelayRejectionReason + = RRRRejoinRefused + | RRRUnknown {text :: Text} + deriving (Eq, Show) + +instance FromField RelayRejectionReason where fromField = blobFieldDecoder strDecode + +instance ToField RelayRejectionReason where toField = toField . strEncode + +instance StrEncoding RelayRejectionReason where + strEncode = \case + RRRRejoinRefused -> "rejoin_refused" + RRRUnknown text -> encodeUtf8 text + strP = + "rejoin_refused" $> RRRRejoinRefused + <|> RRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON RelayRejectionReason where + parseJSON = strParseJSON "RelayRejectionReason" + +instance ToJSON RelayRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index e0630e2e42..1c0032ae6f 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -84,6 +84,7 @@ data RelayStatus | RSAccepted | RSActive | RSInactive + | RSRejected deriving (Eq, Show) relayStatusText :: RelayStatus -> Text @@ -93,6 +94,7 @@ relayStatusText = \case RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" instance TextEncoding RelayStatus where textEncode = \case @@ -101,12 +103,14 @@ instance TextEncoding RelayStatus where RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" textDecode = \case "new" -> Just RSNew "invited" -> Just RSInvited "accepted" -> Just RSAccepted "active" -> Just RSActive "inactive" -> Just RSInactive + "rejected" -> Just RSRejected _ -> Nothing instance FromField RelayStatus where fromField = fromTextField_ textDecode @@ -115,6 +119,15 @@ instance ToField RelayStatus where toField = toField . textEncode $(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) +-- True for relay-own-status values that mean "relay not serving this group". +-- Both RSInactive (relay removed or stopped) and RSRejected (relay refused to rejoin) +-- block normal message delivery; DJRelayRemoved is handled in a status-independent branch. +relayNotServing :: Maybe RelayStatus -> Bool +relayNotServing = \case + Just RSInactive -> True + Just RSRejected -> True + _ -> False + data MsgSigStatus = MSSVerified | MSSSignedNoKey deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1211dc55a9..ff8856664a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -184,6 +184,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results + CRRelayGroupAllowed u g -> ttyUser u [ttyFullGroup g <> ": relay refusal cleared"] CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] @@ -541,6 +542,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TERelayRejected u g reason -> ttyUser u [ttyGroup' g <> ": relay rejected, reason: " <> sShow reason] TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m @@ -1435,11 +1437,18 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs where ldn_ :: GroupInfo -> Text ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName - groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = + groupSS g@GroupInfo { membership + , chatSettings = ChatSettings {enableNtfs} + , groupSummary = GroupSummary {currentMembers} + , relayOwnStatus + } = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" viewMemberStatus = \case GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 467c2d8bdd..0ec63e1224 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -272,6 +272,12 @@ chatGroupTests = do it "should add relay to existing channel" testChannelAddRelay it "should remove relay from channel" testChannelRemoveRelay it "should remove left relay from channel" testChannelRemoveLeftRelay + describe "relay refusal" $ do + it "relay refuses fresh invitation after leaving the same channel" testRelayRefuseAfterLeave + it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain + it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRefuseUnrelatedChannel + it "concurrent fresh invitations both refused" testRelayRefuseRaceConcurrentInvitations + it "deleting a rejected channel is blocked until operator allow" testRelayDeleteRejectedBlocked describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -9920,6 +9926,222 @@ testChannelRemoveLeftRelay ps = DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text] danMembers2 `shouldMatchList` [Only "dan", Only "alice"] +-- | Read the relay_own_status column from a relay's groups table by group_id. +queryRelayOwnStatus :: TestCC -> Int64 -> IO (Maybe T.Text) +queryRelayOwnStatus cc gId = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only gId) + :: IO [Only (Maybe T.Text)] + pure $ case rows of + [Only s] -> s + _ -> Nothing + +-- | All (group_id, relay_own_status) rows on a relay where relay_own_status is set. +listRelayOwnStatuses :: TestCC -> IO [(Int64, T.Text)] +listRelayOwnStatuses cc = + withCCTransaction cc $ \db -> + DB.query_ + db + "SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id" + :: IO [(Int64, T.Text)] + +-- | Directly set relay_own_status on a relay's groups row (test surgery). +setRelayOwnStatus :: TestCC -> Int64 -> T.Text -> IO () +setRelayOwnStatus cc gId status = + withCCTransaction cc $ \db -> + DB.execute db "UPDATE groups SET relay_own_status = ? WHERE group_id = ?" (status, gId) + +-- | Poll for a relay's row count with non-NULL relay_own_status to reach +-- `expected` (e.g. after INFO cleanup removed a transient row). Up to 20 s. +waitForRelayGroupCount :: TestCC -> Int -> IO Int +waitForRelayGroupCount cc expected = loop (40 :: Int) + where + countRows = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL" :: IO [Only Int] + pure $ case rows of + [Only n] -> n + _ -> 0 + loop 0 = countRows + loop n = do + c <- countRows + if c == expected + then pure c + else threadDelay 500000 >> loop (n - 1) + +testRelayRefuseAfterLeave :: HasCallStack => TestParams -> IO () +testRelayRefuseAfterLeave ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + _ <- prepareChannel1Relay "team" alice bob + threadDelay 100000 + + -- relay leaves the channel + bob ##> "/leave #team" + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group" + alice <## "#team: bob left the group (signed)" + threadDelay 100000 + + bobLeaveStatus <- queryRelayOwnStatus bob 1 + bobLeaveStatus `shouldBe` Just "rejected" + + -- owner removes the (now-left) relay member; cascade clears alice's group_relays row + alice ##> "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + -- owner re-adds bob as relay + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + + -- bob's xGrpRelayInv finds the 'rejected' row for this link and sends XGrpRelayReject. + -- alice's CONF handler emits TERelayRejected; the relay row flips to 'rejected'. + alice <## "#team: relay rejected, reason: RRRRejoinRefused" + + -- assert alice's fresh GroupRelay row is marked 'rejected' + aliceRelayStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT relay_status FROM group_relays" :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayStatuses `shouldBe` ["rejected"] + + -- bob's transient row was created with relay_own_status='rejected'; + -- after INFO arrives the cleanup arm deletes it. Original row 1 remains rejected. + _ <- waitForRelayGroupCount bob 1 + finalStatuses <- listRelayOwnStatuses bob + finalStatuses `shouldBe` [(1, "rejected")] + +testRelayAllowAcceptsAgain :: HasCallStack => TestParams -> IO () +testRelayAllowAcceptsAgain ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + _ <- prepareChannel1Relay "team" alice bob + threadDelay 100000 + + bob ##> "/leave #team" + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group" + alice <## "#team: bob left the group (signed)" + threadDelay 100000 + + -- /_relay allow flips bob's row from 'rejected' to 'inactive' + bob ##> "/_relay allow 1" + bob <## "#team: relay refusal cleared" + bobClearStatus <- queryRelayOwnStatus bob 1 + bobClearStatus `shouldBe` Just "inactive" + + -- owner can now re-add and bob accepts as relay + alice ##> "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + alice ##> "/_add relays #1 1" + concurrentlyN_ + [ do + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: group link relays updated, current relays:" + alice .<##. (" - relay id", ": active") + alice <## "group link:" + void $ getTermLine alice, + bob <## "#team_1: you joined the group as relay" + ] + +testRelayDoesNotRefuseUnrelatedChannel :: HasCallStack => TestParams -> IO () +testRelayDoesNotRefuseUnrelatedChannel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + _ <- prepareChannel1Relay "teama" alice bob + threadDelay 100000 + + bob ##> "/leave #teama" + bob <## "#teama: you left the group" + bob <## "use /d #teama to delete the group" + alice <## "#teama: bob left the group (signed)" + threadDelay 100000 + + bobAStatus <- queryRelayOwnStatus bob 1 + bobAStatus `shouldBe` Just "rejected" + + -- alice creates a second channel reusing the same bob relay config. + -- bob's xGrpRelayInv for teamb's link finds no rejection and accepts normally. + _ <- prepareChannel' 2 "teamb" alice bob + threadDelay 100000 + + bobBStatus <- queryRelayOwnStatus bob 2 + bobBStatus `shouldNotBe` Just "rejected" + bobBStatus `shouldNotBe` Nothing + +testRelayRefuseRaceConcurrentInvitations :: HasCallStack => TestParams -> IO () +testRelayRefuseRaceConcurrentInvitations ps = + -- After rejection, multiple sequential re-invitations must all refuse with + -- consistent state (each transient row created with RSRejected and cleaned + -- up by its own INFO). + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + _ <- prepareChannel1Relay "team" alice bob + threadDelay 100000 + + bob ##> "/leave #team" + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group" + alice <## "#team: bob left the group (signed)" + threadDelay 100000 + + -- first refusal + alice ##> "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRefused" + void $ waitForRelayGroupCount bob 1 + + -- second refusal + alice ##> "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRefused" + + _ <- waitForRelayGroupCount bob 1 + finalStatuses <- listRelayOwnStatuses bob + finalStatuses `shouldBe` [(1, "rejected")] + +testRelayDeleteRejectedBlocked :: HasCallStack => TestParams -> IO () +testRelayDeleteRejectedBlocked ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + _ <- prepareChannel1Relay "team" alice bob + threadDelay 100000 + + bob ##> "/leave #team" + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group" + alice <## "#team: bob left the group (signed)" + threadDelay 100000 + + bobStatus <- queryRelayOwnStatus bob 1 + bobStatus `shouldBe` Just "rejected" + + bob ##> "/d #team" + bob <## "bad chat command: cannot delete a rejected channel; run /_relay allow first" + + stillRejected <- queryRelayOwnStatus bob 1 + stillRejected `shouldBe` Just "rejected" + + bob ##> "/_relay allow 1" + bob <## "#team: relay refusal cleared" + + bobInactive <- queryRelayOwnStatus bob 1 + bobInactive `shouldBe` Just "inactive" + + bob ##> "/d #team" + bob <## "#team: you deleted the group" + testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO () testChannelCreateDeletedRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do