diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
index 00c8d7070b..f825dbeca7 100644
--- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
+++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift
@@ -131,6 +131,15 @@ public func subscriberCountStr(_ count: Int64) -> String {
: String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count)
}
+public func ownersContributorsCountStr(_ count: Int, withContributors: Bool) -> String {
+ if withContributors {
+ return String.localizedStringWithFormat(NSLocalizedString("%d owners & contributors", comment: "channel members count"), count)
+ }
+ return count == 1
+ ? String.localizedStringWithFormat(NSLocalizedString("%d owner", comment: "channel owners count"), count)
+ : String.localizedStringWithFormat(NSLocalizedString("%d owners", comment: "channel owners count"), count)
+}
+
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
index 44fc302aff..231054fd78 100644
--- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift
@@ -21,22 +21,29 @@ struct ChannelMembersView: View {
let s = m.wrapped.memberStatus
return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay
}
+ .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
+ let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
if groupInfo.isOwner {
- let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
List {
Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) {
memberRow(GMember(groupInfo.membership), user: true, showRole: true)
ForEach(members) { member in
- memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner)
+ memberRow(member, user: false, showRole: member.wrapped.memberRole >= .member)
}
}
}
} else {
- let owners = members.filter { $0.wrapped.memberRole >= .owner }
+ let contributors = members.filter { $0.wrapped.memberRole >= .member && $0.wrapped.memberStatus != .memUnknown }
+ let contributorCount = contributors.count + (groupInfo.membership.memberRole >= .member ? 1 : 0)
+ let withContributors = contributors.contains { $0.wrapped.memberRole < .owner }
+ || groupInfo.membership.memberRole >= .member
List {
- Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) {
- ForEach(owners) { member in
- memberRow(member, user: false, showRole: false)
+ Section(header: Text(ownersContributorsCountStr(contributorCount, withContributors: withContributors)).foregroundColor(theme.colors.secondary)) {
+ if groupInfo.membership.memberRole >= .member {
+ memberRow(GMember(groupInfo.membership), user: true, showRole: true)
+ }
+ ForEach(contributors) { member in
+ memberRow(member, user: false, showRole: member.wrapped.memberRole >= .moderator)
}
}
}
diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
index 5563d79e61..41e24a6ced 100644
--- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
+++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift
@@ -691,7 +691,7 @@ struct GroupChatInfoView: View {
}
private func channelMembersButton() -> some View {
- let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners"
+ let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors"
return NavigationLink {
ChannelMembersView(chat: chat, groupInfo: groupInfo)
.navigationTitle(label)
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 6f7c746691..cc9e71354c 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -1547,6 +1547,11 @@ fun subscriberCountStr(count: Long): String =
if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count)
else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count)
+fun ownersContributorsCountStr(count: Int, withContributors: Boolean): String =
+ if (withContributors) String.format(generalGetString(MR.strings.channel_owners_contributors_count), count)
+ else if (count == 1) String.format(generalGetString(MR.strings.channel_owner_count_singular), count)
+ else String.format(generalGetString(MR.strings.channel_owner_count_plural), count)
+
@Composable
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) {
Row(
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
index 9f13cf2b19..ef3d8805f4 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
+import chat.simplex.common.views.chat.ownersContributorsCountStr
import chat.simplex.common.views.chat.subscriberCountStr
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
@@ -33,6 +34,7 @@ fun ChannelMembersView(
&& m.memberStatus != GroupMemberStatus.MemRemoved
&& m.memberRole != GroupMemberRole.Relay
}
+ .sortedByDescending { it.memberRole }
ColumnWithScrollBar {
val title = if (groupInfo.isOwner) {
@@ -42,8 +44,8 @@ fun ChannelMembersView(
}
AppBarTitle(title)
+ val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong()
if (groupInfo.isOwner) {
- val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong()
SectionView(title = subscriberCountStr(subscriberCount)) {
SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel)
@@ -55,14 +57,23 @@ fun ChannelMembersView(
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
- ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner, isChannel = groupInfo.isChannel)
+ ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Member, isChannel = groupInfo.isChannel)
}
}
}
} else {
- val owners = members.filter { it.memberRole >= GroupMemberRole.Owner }
- SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) {
- owners.forEachIndexed { index, member ->
+ val contributors = members.filter { it.memberRole >= GroupMemberRole.Member && it.memberStatus != GroupMemberStatus.MemUnknown }
+ val contributorCount = contributors.size + if (groupInfo.membership.memberRole >= GroupMemberRole.Member) 1 else 0
+ val withContributors = contributors.any { it.memberRole < GroupMemberRole.Owner } ||
+ groupInfo.membership.memberRole >= GroupMemberRole.Member
+ SectionView(title = ownersContributorsCountStr(contributorCount, withContributors)) {
+ if (groupInfo.membership.memberRole >= GroupMemberRole.Member) {
+ SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
+ ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel)
+ }
+ Divider()
+ }
+ contributors.forEachIndexed { index, member ->
if (index > 0) {
Divider()
}
@@ -71,7 +82,7 @@ fun ChannelMembersView(
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
- ChannelMemberRow(member, user = false, showRole = false, isChannel = groupInfo.isChannel)
+ ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Moderator, isChannel = groupInfo.isChannel)
}
}
}
diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
index 71c0bf98a4..9262bd9dac 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -2976,9 +2976,12 @@
Subscribers
- Owners
+ Owners & contributors
%1$d subscriber
%1$d subscribers
+ %1$d owner
+ %1$d owners
+ %1$d owners & contributors
you
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index b4487dcf73..f8c599c80b 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 7.0.0.5
+version: 7.0.0.6
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs
index 004d790844..eada7e5a1b 100644
--- a/src/Simplex/Chat/Library/Commands.hs
+++ b/src/Simplex/Chat/Library/Commands.hs
@@ -2266,8 +2266,6 @@ processChatCommand cxt nm = \case
CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile
APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq
Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do
- -- TODO [relays] member: /c api to support groups with relays
- -- TODO - possibly by going through APIPrepareGroup -> APIConnectPreparedGroup
(ccLink, plan) <- connectPlan user cLink False Nothing `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing Nothing)); _ -> throwError e
connectWithPlan user incognito ccLink plan
Connect _ Nothing -> throwChatError CEInvalidConnReq
@@ -4234,8 +4232,27 @@ processChatCommand cxt nm = \case
case plan of
CPContactAddress (CAPContactViaAddress Contact {contactId}) ->
processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId
+ CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _)
+ | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld
_ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink
| otherwise = pure $ CRConnectionPlan user ccLink plan
+ where
+ joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> CM ChatResponse
+ joinChannelViaRelays ccl gld = do
+ GroupInfo {groupId} <- prepareChannelGroup
+ processChatCommand cxt nm APIConnectPreparedGroup {groupId, incognito, ownerContact = Nothing, msgContent_ = Nothing}
+ `catchAllErrors` \e -> do
+ deletePreparedChannel groupId `catchAllErrors` eToView
+ throwError e
+ where
+ prepareChannelGroup =
+ processChatCommand cxt nm (APIPrepareGroup userId ccl False gld) >>= \case
+ CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _)) -> pure gInfo
+ _ -> throwChatError $ CEException "joinChannelViaRelays: unexpected response from APIPrepareGroup"
+ deletePreparedChannel groupId = do
+ gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId
+ deleteGroupConnections user gInfo False
+ withFastStore' $ \db -> deleteGroup db user gInfo
invitationRequestPlan :: User -> ConnReqInvitation -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan
invitationRequestPlan user cReq cld ov = do
maybe (CPInvitationLink (ILPOk cld ov)) (invitationEntityPlan cld ov)
diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs
index 41f70afda4..c7f44d125d 100644
--- a/src/Simplex/Chat/Library/Internal.hs
+++ b/src/Simplex/Chat/Library/Internal.hs
@@ -1256,6 +1256,9 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
isRosterRole :: GroupMemberRole -> Bool
isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin
+isPrivilegedRole :: GroupMemberRole -> Bool
+isPrivilegedRole r = r >= GRMember
+
-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first.
-- Runs on the parsed roster blob.
validateGroupRoster :: [RosterMember] -> [RosterMember]
diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs
index c3f61f83b0..c964df2b3d 100644
--- a/src/Simplex/Chat/Library/Subscriber.hs
+++ b/src/Simplex/Chat/Library/Subscriber.hs
@@ -3071,8 +3071,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do
- let fromRelay = useRelays' gInfo && isRelay m
- unless fromRelay $ checkHostRole m memRole
+ unless (useRelays' gInfo) $ checkHostRole m memRole
if sameMemberId memId (membership gInfo)
then pure Nothing
else
@@ -3081,7 +3080,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- roster-established privileged member: the relay may update the profile only,
-- never the role or key (those are owner-authoritative via the roster, and
-- XGrpMemNew is unsigned)
- | fromRelay && isRosterRole (memberRole' unknownMember) -> do
+ | useRelays' gInfo && isPrivilegedRole (memberRole' unknownMember) -> do
-- a member's key is immutable per memberId and identical across relays; mismatch
-- is unambiguous relay misbehavior (role can legitimately differ across relays
-- under multi-relay skew, so we deliberately don't warn on role)
@@ -3095,8 +3094,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember
memberAnnouncedToView updatedMember gInfo'
pure $ deliveryJobScope updatedMember
- -- asserted privileged but NOT roster-established: relay conjuring a moderator
- | fromRelay && isRosterRole memRole ->
+ -- asserted privileged but NOT roster-established: relay conjuring a privileged member
+ | useRelays' gInfo && isPrivilegedRole memRole ->
messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing
| otherwise -> do
(updatedMember, gInfo') <- withStore $ \db -> do
@@ -3114,8 +3113,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
| useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing
| otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing
Left _
- -- a privileged member absent from the roster is a relay conjuring a moderator
- | fromRelay && isRosterRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing
+ -- a privileged member absent from the roster is a relay conjuring one
+ | useRelays' gInfo && isPrivilegedRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing
| otherwise -> do
(newMember, gInfo') <- withStore $ \db -> do
newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus
@@ -3266,7 +3265,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
| membershipMemId == memId =
applyAtRosterVersion gInfo m rosterVer_ $
let gInfo' = gInfo {membership = membership {memberRole = memRole}}
- in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole
+ in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) (RGEUserRole memRole) True
| otherwise = applyAtRosterVersion gInfo m rosterVer_ $ do
defaultRole <- unknownMemberRole gInfo
-- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup
@@ -3276,11 +3275,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- just created (keyless, and allowCreate ensured the event carries its key): pin key + role
| created, Just (MemberKey pubKey) <- memberKey_ ->
let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
- in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent
+ in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent (not $ useRelays' gInfo)
-- known member: apply the role (its key is established via roster/intro; the event's key is ignored)
| otherwise ->
let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
- in changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent
+ in changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent (not $ useRelays' gInfo)
-- in relay groups the roster may deliver role update for previously-unknown privileged members
_ | useRelays' gInfo -> pure Nothing
| otherwise -> messageError "x.grp.mem.role with unknown member ID" $> Nothing
@@ -3288,7 +3287,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
GroupMember {memberId = membershipMemId} = membership
-- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member);
-- the delivery scope (relay forwarding) is computed on the pre-change role
- changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent
+ changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent createItem
| senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) =
messageError "x.grp.mem.role with insufficient member permissions" $> Nothing
| useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner =
@@ -3298,9 +3297,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
| useRelays' gInfo && not created && fromRole == memRole = pure $ memberEventDeliveryScope member
| otherwise = do
withStore' applyMember
- (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
- (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent)
- groupMsgToView cInfo ci
+ (gInfo'', m') <-
+ if createItem
+ then do
+ (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
+ (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent)
+ groupMsgToView cInfo ci
+ pure (gInfo'', m')
+ else pure (gInfo', m)
toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned}
pure $ memberEventDeliveryScope member
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
index fb6166f8c9..eefcb8de57 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
@@ -7033,6 +7033,11 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
+Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?
+Plan:
+SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name
+SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?)
+
Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ?
Plan:
SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?)
@@ -7233,6 +7238,10 @@ Query: SELECT max(active_order) FROM users
Plan:
SEARCH users
+Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1
+Plan:
+SCAN group_members
+
Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ?
Plan:
SCAN group_members
@@ -7253,6 +7262,10 @@ Query: SELECT member_role FROM group_members WHERE local_display_name = ?
Plan:
SCAN group_members
+Query: SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ?
+Plan:
+SCAN group_members
+
Query: SELECT member_status FROM group_members WHERE local_display_name = ?
Plan:
SCAN group_members
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index a32aa2d07c..51fe8c54aa 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -3,6 +3,7 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
@@ -16,30 +17,37 @@ import ChatTests.DBUtils
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
+import Control.Concurrent.STM (atomically)
import Control.Monad (forM_, void, when)
+import Control.Monad.Except (runExceptT)
import Data.Bifunctor (second)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Maybe (fromMaybe, isJust, maybeToList)
-import Data.Time (UTCTime)
+import Data.Time (UTCTime, getCurrentTime)
import Data.Int (Int64)
import Data.List (intercalate, isInfixOf, isSuffixOf)
import qualified Data.Map.Strict as M
import qualified Data.Text as T
-import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks)
+import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks)
import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames)
import Simplex.Chat.Markdown (parseMaybeMarkdownList)
import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId)
+import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement)
import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText)
import Simplex.Chat.Options
-import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText)
+import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText)
import Simplex.Chat.Types
import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation)
import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..))
+import Simplex.Messaging.Agent (sendMessages, vrValue)
import Simplex.Messaging.Agent.Env.SQLite
import Simplex.Messaging.Agent.RetryInterval
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Agent.Store.DB (Binary (..))
+import qualified Simplex.Messaging.Crypto as C
+import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff)
+import Simplex.Messaging.Protocol (MsgFlags (..))
import Simplex.Messaging.Server.Env.STM hiding (subscriptions)
import Simplex.Messaging.Transport
import Simplex.Messaging.Version
@@ -255,6 +263,8 @@ chatGroupTests = do
describe "multiple relays" $ do
it "2 relays: should deliver messages to members" testChannels2RelaysDeliver
it "should share same incognito profile with all relays" testChannels2RelaysIncognito
+ it "should connect to channel via /c (CLI)" testConnectChannelCLI
+ it "should connect to channel via /c incognito (CLI)" testConnectChannelCLIIncognito
describe "deliver member profiles via relay" $ do
it "late joiner (no prior history) learns sender on first forward" testChannelLateJoinerReceivesProfile
it "2 relays: deduplicate member announcement" testChannel2RelaysDeduplicateProfile
@@ -293,6 +303,7 @@ chatGroupTests = do
it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster
it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster
it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember
+ it "malicious relay cannot forge a privileged member via XGrpMemNew forwarded as the owner" testChannelRelayCannotForgePrivilegedMember
it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster
it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly
it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected
@@ -8644,6 +8655,61 @@ testSupportPreferenceChannel ps =
bob <# "#team (support) alice> yes [>>]"
]
+testConnectChannelCLI :: HasCallStack => TestParams -> IO ()
+testConnectChannelCLI ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan -> do
+ (shortLink, _fullLink) <- prepareChannel2Relays "team" alice bob cath
+ relayNames <- mapM userName [bob, cath]
+ mName <- userName dan
+ mFullName <- showName dan
+ dan ##> ("/c " <> shortLink)
+ dan <## "#team: connection started"
+ concurrentlyN_ $
+ [ dan
+ <### concat
+ [ [ ConsoleString ("#team: joining the group (connecting to relay " <> rName <> ")..."),
+ ConsoleString ("#team: you joined the group (connected to relay " <> rName <> ")")
+ ]
+ | rName <- relayNames
+ ]
+ ]
+ <> [ do
+ relay <## (mFullName <> ": accepting request to join group #team...")
+ relay <## ("#team: " <> mName <> " joined the group")
+ | relay <- [bob, cath]
+ ]
+ <> [alice <### [EndsWith ("introduced " <> mFullName <> " in the channel")]]
+
+testConnectChannelCLIIncognito :: HasCallStack => TestParams -> IO ()
+testConnectChannelCLIIncognito ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan -> do
+ (shortLink, _fullLink) <- prepareChannel2Relays "team" alice bob cath
+ relayNames <- mapM userName [bob, cath]
+ dan ##> ("/c i " <> shortLink)
+ danIncognito <- getTermLine dan
+ dan <## "#team: connection started incognito"
+ concurrentlyN_ $
+ [ dan
+ <### concat
+ [ [ ConsoleString ("#team: joining the group (connecting to relay " <> rName <> ")..."),
+ ConsoleString ("#team: you joined the group (connected to relay " <> rName <> ") incognito as " <> danIncognito)
+ ]
+ | rName <- relayNames
+ ]
+ ]
+ <> [ do
+ relay <## (danIncognito <> ": accepting request to join group #team...")
+ relay <## ("#team: " <> danIncognito <> " joined the group")
+ | relay <- [bob, cath]
+ ]
+ <> [alice <### [EndsWith ("introduced " <> danIncognito <> " in the channel")]]
+
testChannels1RelayDeliver :: HasCallStack => TestParams -> IO ()
testChannels1RelayDeliver ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -9314,6 +9380,13 @@ testChannels2RelaysIncognito ps =
frank <# ("#team " <> danIncognito <> "> > hi")
frank <## " + 👍"
+ alice `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve", "frank"]
+ bob `hasContactProfiles` ["alice", "bob", T.pack danIncognito, "eve", "frank"]
+ cath `hasContactProfiles` ["alice", "cath", T.pack danIncognito, "eve", "frank"]
+ dan `hasContactProfiles` ["alice", "bob", "cath", "dan", T.pack danIncognito]
+ eve `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve"]
+ frank `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "frank"]
+
testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO ()
testChannelUpdateProfileSigned ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
@@ -9515,6 +9588,8 @@ testChannelChangeRoleSigned ps =
-- promote cath to member (observer default) so it can post
promoteChannelMember "team" alice bob cath [dan, eve]
+ threadDelay 1000000
+
-- other members discover cath
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
@@ -9540,14 +9615,14 @@ testChannelChangeRoleSigned ps =
dan <## "#team: alice changed the role of cath from member to admin (signed)",
eve <## "#team: alice changed the role of cath from member to admin (signed)"
]
+ -- chat item is not created for other members
alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of cath to admin (signed)")])
- bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
+ bob #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")])
cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")])
- dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
- eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
+ dan #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")])
+ eve #$> ("/_get chat #1 count=1", chat, [(0, "hello from cath")])
- -- change role of silent member; cath/eve don't know dan via xGrpMemRole, but the
- -- subsequent roster apply emits the chat item with dan TOFU-created at the new role
+ -- change role of silent member
threadDelay 1000000
alice ##> "/mr #team dan admin"
alice <## "#team: you changed the role of dan to admin (signed)"
@@ -9557,9 +9632,7 @@ testChannelChangeRoleSigned ps =
cath .<##. ("#team: alice changed the role of ", " from observer to admin (signed)"),
eve .<##. ("#team: alice changed the role of ", " from observer to admin (signed)")
]
- -- cath/eve render dan by id hash (unknown to them, roster-TOFU); arrival verified above
alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")])
- bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")])
dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")])
testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO ()
@@ -9849,6 +9922,68 @@ testChannelRelayCannotDowngradeRosterMember ps =
[Only k] -> pure k
_ -> fail $ "expected one row for " <> T.unpack name
+testChannelRelayCannotForgePrivilegedMember :: HasCallStack => TestParams -> IO ()
+testChannelRelayCannotForgePrivilegedMember ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ threadDelay 1000000
+ -- the forged attribution only resolves to a privileged author if the victim already holds the
+ -- owner at GROwner (established via the group link on join) - this documents and guards that premise
+ checkMemberRow cath "alice" (Just "owner")
+ ownerMemId <- ownerMemberId bob
+ connId <- relayConnIdToMember bob "cath"
+ -- the malicious relay forges the announcement, choosing the new member's role and signing key
+ g <- C.newRandom
+ kp <- atomically $ C.generateKeyPair g
+ ts <- getCurrentTime
+ let ChatController {smpAgent = bobAgent} = chatController bob
+ attackerPub = fst kp :: C.PublicKeyEd25519
+ forgedMemId = MemberId "forgedadmin1"
+ forgedProfile = (aliceProfile :: Profile) {displayName = "forgery", fullName = "Forgery"}
+ memInfo =
+ MemberInfo
+ { memberId = forgedMemId,
+ memberRole = GRAdmin,
+ v = Nothing,
+ profile = forgedProfile,
+ memberKey = Just (MemberKey attackerPub)
+ }
+ chatMsg = ChatMessage chatInitialVRange Nothing (XGrpMemNew memInfo Nothing)
+ fwd = GrpMsgForward (FwdMember ownerMemId "alice") ts
+ body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)]
+ sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]
+ either (fail . show) (const $ pure ()) sent
+ -- secure: the victim rejects the forged privileged announcement instead of storing it
+ cath <##. "error: x.grp.mem.new: privileged member not established by roster"
+ forged <- forgedMemberRows cath "forgery"
+ forged `shouldBe` []
+ where
+ ownerMemberId :: TestCC -> IO MemberId
+ ownerMemberId cc = do
+ rows <- withCCTransaction cc $ \db ->
+ DB.query db "SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1" (Only ("owner" :: T.Text)) :: IO [Only ByteString]
+ case rows of
+ [Only mid] -> pure (MemberId mid)
+ _ -> fail "expected exactly one owner member on the relay"
+ relayConnIdToMember :: TestCC -> T.Text -> IO ByteString
+ relayConnIdToMember cc name = do
+ rows <- withCCTransaction cc $ \db ->
+ DB.query
+ db
+ "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?"
+ (Only name) ::
+ IO [Only ByteString]
+ case rows of
+ (Only connId : _) -> pure connId
+ _ -> fail $ "no relay connection to member " <> T.unpack name
+ forgedMemberRows :: TestCC -> T.Text -> IO [(T.Text, Maybe ByteString)]
+ forgedMemberRows cc name =
+ withCCTransaction cc $ \db ->
+ DB.query db "SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ?" (Only name)
+
testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO ()
testChannelRemoveMemberSigned ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->