mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-03 02:31:50 +00:00
wip
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <groupId>`. |
|
||||
| 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
|
||||
|
||||
@@ -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 <groupId>` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) |
|
||||
|
||||
### File Transfer Events
|
||||
|
||||
|
||||
@@ -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 <groupId>`, 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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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 <groupId>` (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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 <groupId> 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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 [])
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <groupId> 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
|
||||
|
||||
Reference in New Issue
Block a user