This commit is contained in:
spaced4ndy
2026-06-10 18:09:09 +04:00
parent a5e9afa255
commit d2a914d0de
8 changed files with 343 additions and 144 deletions
+2 -2
View File
@@ -2739,7 +2739,7 @@ processChatCommand cxt nm = \case
when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $
throwCmdError "only the group owner can change moderator and admin roles"
when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $
throwCmdError $ "the number of moderators and admins would exceed the limit of " <> show maxGroupRosterSize
throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize
(errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems
(errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems
unless (null acis) $ toView $ CEvtNewChatItems user acis
@@ -2865,7 +2865,7 @@ processChatCommand cxt nm = \case
when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected"
assertUserGroupRole gInfo $ max GRAdmin maxRole
when (useRelays' gInfo && anyPrivilegedRemoved && memberRole' (membership gInfo) /= GROwner) $
throwCmdError "only the group owner can remove moderators and admins"
throwCmdError "only the group owner can remove members, moderators and admins"
(errs1, deleted1) <- deleteInvitedMems user invitedMems
let recipients = filter memberCurrent members
(errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems
+5 -5
View File
@@ -1239,7 +1239,7 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
-- Roles carried by the roster; owners are on the link, not the roster.
isRosterRole :: GroupMemberRole -> Bool
isRosterRole r = r == GRModerator || r == GRAdmin
isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin
-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first.
-- Runs on the parsed roster blob.
@@ -1256,8 +1256,8 @@ validateGroupRoster entries =
buildGroupRoster :: [GroupMember] -> [RosterMember]
buildGroupRoster mods = mapMaybe rosterMember mods
where
rosterMember m@GroupMember {memberId, memberPubKey, memberRole}
| isRosterRole memberRole = (\k -> RosterMember {memberId, name = memberShortenedName m, key = MemberKey k, role = memberRole}) <$> memberPubKey
rosterMember GroupMember {memberId, memberPubKey, memberRole}
| isRosterRole memberRole = (\k -> RosterMember {memberId, key = MemberKey k, role = memberRole, privileges = 0}) <$> memberPubKey
| otherwise = Nothing
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
@@ -2212,7 +2212,7 @@ bumpAndBroadcastRoster user gInfo = do
let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo)
(relays, mods) <- withStore' $ \db -> do
relays <- getGroupRelayMembers db cxt user gInfo
mods <- getGroupRosterMembers db cxt user gInfo
mods <- (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo
setGroupRosterVersion db gInfo rosterVer
pure (relays, mods)
forM_ (L.nonEmpty relays) $ \relays' ->
@@ -2223,7 +2223,7 @@ sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM ()
sendGroupRosterToRelay user gInfo relayMember =
forM_ (rosterVersion gInfo) $ \rosterVer -> do
cxt <- chatStoreCxt
mods <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo
mods <- withStore' $ \db -> (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo
sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster mods)
-- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's.
+6 -8
View File
@@ -3209,7 +3209,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
| senderRole < GRAdmin || senderRole < fromRole =
messageError "x.grp.mem.role with insufficient member permissions" $> Nothing
| useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner =
messageError "x.grp.mem.role: only the owner can change moderator/admin roles in relay groups" $> Nothing
messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing
| otherwise = do
withStore' $ \db -> updateGroupMemberRole db user member memRole
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
@@ -3224,6 +3224,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
xGrpRoster gInfo author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs
-- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls
| memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing
| fileSize > maxGroupRosterBytes = messageError "x.grp.roster: roster blob size exceeds limit" $> Nothing
| otherwise = case verifiedMsg of
-- unreachable: XGrpRoster is in requiresSignature, so withVerifiedMsg rejected unsigned
VMUnsigned _ -> pure Nothing
@@ -3307,7 +3308,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
members <- withStore' $ \db -> getGroupMembers db cxt user gInfo
serveRoster user gInfo (filter rosterRecipient members) `catchAllErrors` eToView
where
rosterRecipient m@GroupMember {activeConn} = memberCurrent m && isJust activeConn && not (isRelay m) && memberRole' m /= GROwner
rosterRecipient m = memberCurrent m && not (isRelay m) && memberRole' m /= GROwner && isJust (readyMemberConn m)
-- TOFU apply: pin each member's key on first use, then update roles.
processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole)])
@@ -3315,18 +3316,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
let rosterIds = map (\RosterMember {memberId} -> memberId) entries
acc <- foldrM applyRosterEntry ([], []) entries
-- absent privileged members revert to the joiner default
currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo
currentPriv <- liftIO $ (++) <$> getGroupRosterMembers db cxt user gInfo <*> getGroupOnlyMembers db cxt user gInfo
liftIO $ forM_ currentPriv $ \m ->
when (memberId' m `notElem` rosterIds) $
updateGroupMemberRole db user m defaultRole
pure acc
where
-- entry-level failure (StoreError or IO exception) is muted; the entry is dropped
applyRosterEntry RosterMember {memberId, name, key = MemberKey pubKey, role} (cs, as) =
applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) =
apply `catchAllErrors` \_ -> pure (cs, as)
where
applied m = (cs, ((m :: GroupMember) {memberRole = role}, memberRole' m) : as)
apply = getCreateUnknownGMByMemberId db cxt user gInfo memberId name defaultRole True >>= \case
apply = getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case
Nothing -> pure (cs, as)
Just (m, _) -> case memberPubKey m of
Just k
@@ -3510,9 +3511,6 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo''' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft)
groupMsgToView cInfo ci
toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned
-- a privileged leaver drops out of the roster
when (useRelays' gInfo'' && memberRole' (membership gInfo'') == GROwner && isRosterRole (memberRole' m)) $
bumpAndBroadcastRoster user gInfo'' `catchAllErrors` eToView
pure $ memberEventDeliveryScope m
xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM ()
+10 -5
View File
@@ -388,9 +388,9 @@ data InlineFileInvitation = InlineFileInvitation
data RosterMember = RosterMember
{ memberId :: MemberId,
name :: Text,
key :: MemberKey, -- trust-on-first-use pinned per memberId
role :: GroupMemberRole
role :: GroupMemberRole,
privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1
}
deriving (Eq, Show)
@@ -398,7 +398,7 @@ data RosterMember = RosterMember
-- The blob codec (encodeRosterBlob / rosterBlobP) is defined with maxGroupRosterSize below,
-- so rosterBlobP and the bound it references sit in one declaration group (past the TH splices).
instance Encoding RosterMember where
smpEncode RosterMember {memberId, name, key, role} = smpEncode (memberId, name, key, role)
smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges)
smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
instance Encoding FwdSender where
@@ -930,10 +930,15 @@ maxDecompressedMsgLength :: Int
maxDecompressedMsgLength = 65536
-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
-- promotion cap over the privileged (moderator/admin) set. The blob rides over the
-- promotion cap over the promoted (member/moderator/admin) set. The blob rides over the
-- inline file transfer, so it is no longer bound by maxEncodedMsgLength.
maxGroupRosterSize :: Int
maxGroupRosterSize = 64
maxGroupRosterSize = 256
-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what
-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created.
maxGroupRosterBytes :: Integer
maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128
-- The byte sequence the owner-signed digest is computed over and verified against
-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap).
+9
View File
@@ -68,6 +68,7 @@ module Simplex.Chat.Store.Groups
getSupportScopeMembersByIndexes,
getGroupModerators,
getGroupRosterMembers,
getGroupOnlyMembers,
getGroupOwners,
getGroupRelayMembers,
getGroupMembersForExpiration,
@@ -1224,6 +1225,14 @@ getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupI
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)")
(userId, groupId, userContactId, GRModerator, GRAdmin)
getGroupOnlyMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupOnlyMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
filter memberCurrent . map (toContactMember cxt user)
<$> DB.query
db
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?")
(userId, groupId, userContactId, GRMember)
getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
filter memberCurrent . map (toContactMember cxt user)