mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 05:24:44 +00:00
wip
This commit is contained in:
@@ -2536,7 +2536,7 @@ processChatCommand vr nm = \case
|
||||
memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12)
|
||||
(memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey
|
||||
let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId}}
|
||||
userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just PublicGroupData {publicMemberCount = 1, rosterVersion = Nothing}}
|
||||
userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)}
|
||||
userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData}
|
||||
-- create connection with prepared link (single network call)
|
||||
connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode
|
||||
@@ -2727,14 +2727,15 @@ processChatCommand vr nm = \case
|
||||
-- TODO [relays] possible optimization is to read only required members + relays
|
||||
g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
when (selfSelected gInfo) $ throwCmdError "can't change role for self"
|
||||
let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members
|
||||
let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, finalPrivilegedCount) = selectMembers members
|
||||
when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound
|
||||
when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $
|
||||
throwCmdError "can't change role of multiple members when admins selected, or new role is admin"
|
||||
when anyPending $ throwCmdError "can't change role of members pending approval"
|
||||
assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole])
|
||||
let finalRole m = if groupMemberId' m `elem` memberIds then newRole else memberRole' m
|
||||
finalPrivilegedCount = length $ filter (isRosterRole . finalRole) members
|
||||
-- in relay groups the roster has a single signer, so only the owner may change moderator/admin roles
|
||||
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
|
||||
(errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems
|
||||
@@ -2742,30 +2743,28 @@ processChatCommand vr nm = \case
|
||||
unless (null acis) $ toView $ CEvtNewChatItems user acis
|
||||
let errs = errs1 <> errs2
|
||||
unless (null errs) $ toView $ CEvtChatErrors errs
|
||||
-- Broadcast the owner-signed roster when the {moderator, admin} set changed
|
||||
-- (entered/moved within → newRole privileged; left → a changed target was privileged).
|
||||
let rosterSetChanged
|
||||
| not (useRelays' gInfo) = False
|
||||
| isRosterRole newRole = not $ null $ changed1 <> changed2
|
||||
| otherwise = any (isRosterRole . memberRole') (invitedMems <> currentMems)
|
||||
let rosterSetChanged = useRelays' gInfo && not (null (changed1 <> changed2)) && (isRosterRole newRole || anyPrivilegedTarget)
|
||||
when rosterSetChanged $ bumpAndBroadcastRoster user gInfo `catchAllErrors` eToView
|
||||
pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole, msgSigned} -- same order is not guaranteed
|
||||
where
|
||||
selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds
|
||||
selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool)
|
||||
selectMembers = foldr' addMember ([], [], [], GRObserver, False, False)
|
||||
-- anyPrivilegedTarget: a target currently moderator/admin; finalPrivilegedCount:
|
||||
-- moderators + admins after the change (targets take newRole, others keep their role).
|
||||
selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Int)
|
||||
selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, 0)
|
||||
where
|
||||
addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending)
|
||||
addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, privCount)
|
||||
| groupMemberId `elem` memberIds =
|
||||
let maxRole' = max maxRole memberRole
|
||||
anyAdmin' = anyAdmin || memberRole >= GRAdmin
|
||||
anyPending' = anyPending || memberPending m
|
||||
in
|
||||
if
|
||||
| memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending')
|
||||
| memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending')
|
||||
| otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending')
|
||||
| otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending)
|
||||
anyPrivTarget' = anyPrivTarget || isRosterRole memberRole
|
||||
privCount' = if isRosterRole newRole then privCount + 1 else privCount
|
||||
in if
|
||||
| memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
|
||||
| memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
|
||||
| otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
|
||||
| otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, if isRosterRole memberRole then privCount + 1 else privCount)
|
||||
changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember])
|
||||
changeRoleInvitedMems user gInfo memsToChange = do
|
||||
-- not batched, as we need to send different invitations to different connections anyway
|
||||
@@ -2856,7 +2855,7 @@ processChatCommand vr nm = \case
|
||||
withGroupLock "removeMembers" groupId $ do
|
||||
-- TODO [relays] possible optimization is to read only required members + relays
|
||||
Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId
|
||||
let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members
|
||||
let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved) = selectMembers gmIds members
|
||||
gmIds = S.fromList $ L.toList groupMemberIds
|
||||
memCount = length groupMemberIds
|
||||
when (count /= memCount) $ throwChatError CEGroupMemberNotFound
|
||||
@@ -2882,27 +2881,25 @@ processChatCommand vr nm = \case
|
||||
let acis' = map (updateACIGroupInfo gInfo') acis
|
||||
unless (null acis') $ toView $ CEvtNewChatItems user acis'
|
||||
unless (null errs) $ toView $ CEvtChatErrors errs
|
||||
-- Refresh the roster when a privileged member was removed, so the relay's
|
||||
-- cached roster (served to joiners) drops them. XGrpMemDel already
|
||||
-- neutralizes them for existing members; the roster broadcast self-heals
|
||||
-- the privileged-set side and is accepted as one redundant admin message.
|
||||
let removedPrivileged = useRelays' gInfo && any (isRosterRole . memberRole') (invitedMems <> currentMems <> pendingApprvMems <> pendingRvwMems)
|
||||
when removedPrivileged $ bumpAndBroadcastRoster user gInfo `catchAllErrors` eToView
|
||||
-- refresh the roster so the relay drops a removed privileged member for future joiners
|
||||
when (useRelays' gInfo && anyPrivilegedRemoved) $ bumpAndBroadcastRoster user gInfo `catchAllErrors` eToView
|
||||
pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed
|
||||
where
|
||||
selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool)
|
||||
selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False)
|
||||
|
||||
selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool)
|
||||
selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False, False)
|
||||
where
|
||||
addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole}
|
||||
addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin, anyPrivRemoved) m@GroupMember {groupMemberId, memberStatus, memberRole}
|
||||
| groupMemberId `S.member` gmIds =
|
||||
let maxRole' = max maxRole memberRole
|
||||
anyAdmin' = anyAdmin || memberRole >= GRAdmin
|
||||
anyPrivRemoved' = anyPrivRemoved || isRosterRole memberRole
|
||||
n' = n + 1
|
||||
in case memberStatus of
|
||||
GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin')
|
||||
GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin')
|
||||
GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin')
|
||||
_ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin')
|
||||
GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
|
||||
GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
|
||||
GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
|
||||
_ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin', anyPrivRemoved')
|
||||
| otherwise = acc
|
||||
deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember])
|
||||
deleteInvitedMems user memsToDelete = do
|
||||
|
||||
@@ -48,7 +48,6 @@ import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe)
|
||||
import qualified Data.Set as S
|
||||
import Data.Text (Text)
|
||||
import Data.Word (Word32)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time (addUTCTime)
|
||||
@@ -96,7 +95,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn)
|
||||
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
|
||||
import Simplex.Messaging.Encoding (smpEncode)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Protocol (MsgBody, MsgFlags (..), ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
@@ -1160,42 +1159,29 @@ memberIntroEvt gInfo reMember =
|
||||
mRestrictions = memberRestrictions reMember
|
||||
in XGrpMemIntro mInfo mRestrictions
|
||||
|
||||
-- Forward the cached owner-signed roster verbatim, attributed to the owner who
|
||||
-- sent it, so the recipient verifies the owner signature.
|
||||
forwardCachedRoster :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
forwardCachedRoster user gInfo subscriber = do
|
||||
vr <- chatVersionRange
|
||||
withStore' (\db -> getCachedGroupRoster db (groupId' gInfo)) >>= \case
|
||||
Nothing -> pure ()
|
||||
Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}) ->
|
||||
forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg ->
|
||||
withStore' (\db -> runExceptT $ getGroupMemberById db vr user ownerGMId) >>= \case
|
||||
Right owner -> do
|
||||
let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs}
|
||||
sendFwdMemberMessage subscriber fwd (VMSigned MSSVerified sm chatMsg)
|
||||
Left _ -> pure ()
|
||||
|
||||
-- Used in groups with relays to introduce moderators and above to a new member,
|
||||
-- and to announce the new member to moderators and above.
|
||||
-- | Forward the relay's cached owner-signed roster to a member verbatim (so the
|
||||
-- owner signature verifies on the recipient). Sent first on join, so a joiner
|
||||
-- learns the privileged set and keys before any relay-asserted introduction.
|
||||
-- The forward attributes the roster to the group owner (single-owner MVP;
|
||||
-- multi-owner roster signing is deferred) so the recipient verifies it against
|
||||
-- the link-anchored owner key.
|
||||
forwardCachedRosterToMember :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
forwardCachedRosterToMember user gInfo subscriber = do
|
||||
vr <- chatVersionRange
|
||||
cached <- withStore' $ \db -> getCachedGroupRoster db (groupId' gInfo)
|
||||
forM_ cached $ \(_ver, bytes) -> case reconstructSignedRoster bytes of
|
||||
Nothing -> logError "forwardCachedRosterToMember: could not decode cached roster"
|
||||
Just (signedMsg, chatMsg) -> do
|
||||
owners <- withStore' $ \db -> filter ((== GROwner) . memberRole') <$> getGroupModerators db vr user gInfo
|
||||
case owners of
|
||||
owner : _ -> do
|
||||
fwdBrokerTs <- liftIO getCurrentTime
|
||||
let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs}
|
||||
sendFwdMemberMessage subscriber fwd (VMSigned MSSVerified signedMsg chatMsg)
|
||||
[] -> logError "forwardCachedRosterToMember: no owner to attribute the roster to"
|
||||
where
|
||||
reconstructSignedRoster bytes = case smpDecode bytes of
|
||||
Right sm@SignedMsg {signedBody} -> case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of
|
||||
Right chatMsg -> Just (sm, chatMsg)
|
||||
Left _ -> Nothing
|
||||
Left _ -> Nothing
|
||||
|
||||
-- This doesn't create introduction records in db, compared to above methods.
|
||||
introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM ()
|
||||
introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
|
||||
introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do
|
||||
-- Forward the cached roster first, so the joiner trusts mod/admin keys before
|
||||
-- any relay-asserted introduction (the per-mod intro is no longer the trust path).
|
||||
forwardCachedRosterToMember user gInfo subscriber
|
||||
-- roster first, so the joiner trusts mod/admin keys before relay intros
|
||||
forwardCachedRoster user gInfo subscriber
|
||||
modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo
|
||||
void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing
|
||||
withStore' $ \db ->
|
||||
@@ -1235,41 +1221,27 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
|
||||
| allowSimplexLinks = Just s
|
||||
| otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s
|
||||
|
||||
-- | Build the owner-signed privileged roster (moderators + admins) at the given
|
||||
-- version. Owners are excluded (their keys come from the link, not the roster).
|
||||
-- A privileged member without a known public key cannot be verified by
|
||||
-- recipients, so it cannot be rostered — skipped rather than emitting a keyless
|
||||
-- entry (it will be included once its key is known).
|
||||
-- | Roles carried by the owner-signed roster: moderators and admins. Owners are
|
||||
-- on the link (not the roster); members/observers are not privileged.
|
||||
-- Roles carried by the roster; owners are on the link, not the roster.
|
||||
isRosterRole :: GroupMemberRole -> Bool
|
||||
isRosterRole r = r == GRModerator || r == GRAdmin
|
||||
|
||||
-- | Sanitize a received roster: drop entries whose role is not privileged and
|
||||
-- de-duplicate by memberId (keep first). The roster is owner-signed, so a
|
||||
-- malformed entry signals an owner bug rather than an attack — dropping the bad
|
||||
-- entry keeps the legitimate entries effective (vs. rejecting the whole roster).
|
||||
-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first.
|
||||
validateGroupRoster :: GroupRoster -> GroupRoster
|
||||
validateGroupRoster GroupRoster {version = v, roster = entries} =
|
||||
GroupRoster {version = v, roster = dedup [] $ filter (isRosterRole . rmRole) entries}
|
||||
validateGroupRoster GroupRoster {version, roster = entries} =
|
||||
GroupRoster {version, roster = dedup [] $ filter (\RosterMember {role} -> isRosterRole role) entries}
|
||||
where
|
||||
rmRole RosterMember {role} = role
|
||||
dedup _ [] = []
|
||||
dedup seen (rm@RosterMember {memberId} : rms)
|
||||
| memberId `elem` seen = dedup seen rms
|
||||
| otherwise = rm : dedup (memberId : seen) rms
|
||||
|
||||
buildGroupRoster :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Word32 -> IO GroupRoster
|
||||
buildGroupRoster db vr user gInfo rosterVer = do
|
||||
mods <- getGroupModerators db vr user gInfo
|
||||
pure GroupRoster {version = rosterVer, roster = mapMaybe rosterMember mods}
|
||||
-- Privileged members without a known key are skipped (recipients can't verify them).
|
||||
buildGroupRoster :: Int -> [GroupMember] -> GroupRoster
|
||||
buildGroupRoster version mods = GroupRoster {version, roster = mapMaybe rosterMember mods}
|
||||
where
|
||||
rosterMember m@GroupMember {memberId, memberPubKey}
|
||||
| isRosterRole role =
|
||||
(\k -> RosterMember {memberId, name = memberShortenedName m, key = MemberKey k, role}) <$> memberPubKey
|
||||
rosterMember m@GroupMember {memberId, memberPubKey, memberRole}
|
||||
| isRosterRole memberRole = (\k -> RosterMember {memberId, name = memberShortenedName m, key = MemberKey k, role = memberRole}) <$> memberPubKey
|
||||
| otherwise = Nothing
|
||||
where
|
||||
role = memberRole' m
|
||||
|
||||
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
|
||||
@@ -1396,9 +1368,9 @@ setGroupLinkData' nm user gInfo =
|
||||
setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink
|
||||
setGroupLinkData nm user gInfo gLink = do
|
||||
vr <- chatVersionRange
|
||||
(conn, groupRelays, rosterVersion) <- withFastStore $ \db ->
|
||||
(,,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) <*> liftIO (getGroupRosterVersion db (groupId' gInfo))
|
||||
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays rosterVersion
|
||||
(conn, groupRelays) <- withFastStore $ \db ->
|
||||
(,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo)
|
||||
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays
|
||||
linkType = if useRelays' gInfo then CCTChannel else CCTGroup
|
||||
sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData))
|
||||
withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk
|
||||
@@ -1406,9 +1378,9 @@ setGroupLinkData nm user gInfo gLink = do
|
||||
setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM ()
|
||||
setGroupLinkDataAsync user gInfo gLink = do
|
||||
vr <- chatVersionRange
|
||||
(conn, groupRelays, rosterVersion) <- withStore $ \db ->
|
||||
(,,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo) <*> liftIO (getGroupRosterVersion db (groupId' gInfo))
|
||||
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays rosterVersion
|
||||
(conn, groupRelays) <- withStore $ \db ->
|
||||
(,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getConnectedGroupRelays db gInfo)
|
||||
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays
|
||||
setAgentConnShortLinkAsync user conn userLinkData (Just crClientData)
|
||||
|
||||
connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM ()
|
||||
@@ -1454,11 +1426,11 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = G
|
||||
_ -> False
|
||||
|
||||
-- TODO [relays] owner: set owners on updating link data (multi-owner)
|
||||
groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> Maybe Word32 -> (UserConnLinkData 'CMContact, CRClientData)
|
||||
groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays rosterVersion =
|
||||
groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData)
|
||||
groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays =
|
||||
let direct = not $ useRelays' gInfo
|
||||
relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays
|
||||
publicGroupData_ = (\count -> PublicGroupData {publicMemberCount = count, rosterVersion}) <$> publicMemberCount
|
||||
publicGroupData_ = PublicGroupData <$> publicMemberCount
|
||||
userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_}
|
||||
owners = case groupKeys of
|
||||
Just GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} ->
|
||||
@@ -2168,36 +2140,29 @@ sendGroupMessage' user gInfo members chatMsgEvent =
|
||||
-- members and future joiners), and persist the new version. Called when
|
||||
-- APIMembersRole changes the {moderator, admin} set. The owner signature is
|
||||
-- applied automatically by groupMsgSigning (XGrpRoster requiresSignature).
|
||||
-- Only the owner can re-sign the roster; sendGroupMessage' is used (not per-member
|
||||
-- senders) as it applies groupMsgSigning and the roster must be owner-signed.
|
||||
bumpAndBroadcastRoster :: User -> GroupInfo -> CM ()
|
||||
bumpAndBroadcastRoster user gInfo
|
||||
-- Only the owner holds the roster-signing key (single-owner MVP); a non-owner
|
||||
-- role change still propagates via XGrpMemRole but cannot re-sign the roster.
|
||||
| memberRole' (membership gInfo) /= GROwner = pure ()
|
||||
| otherwise = do
|
||||
vr <- chatVersionRange
|
||||
let groupId = groupId' gInfo
|
||||
(relays, rosterVer) <- withStore' $ \db -> do
|
||||
rs <- getGroupRelayMembers db vr user gInfo
|
||||
cur <- getGroupRosterVersion db groupId
|
||||
pure (rs, maybe 0 (+ 1) cur)
|
||||
roster <- withStore' $ \db -> buildGroupRoster db vr user gInfo rosterVer
|
||||
let rosterVer = maybe 0 (+ 1) (rosterVersion gInfo)
|
||||
(relays, roster) <- withStore' $ \db -> do
|
||||
relays <- getGroupRelayMembers db vr user gInfo
|
||||
mods <- getGroupModerators db vr user gInfo
|
||||
setGroupRosterVersion db (groupId' gInfo) rosterVer
|
||||
pure (relays, buildGroupRoster rosterVer mods)
|
||||
forM_ (L.nonEmpty relays) $ \relays' ->
|
||||
void $ sendGroupMessage' user gInfo (L.toList relays') (XGrpRoster roster)
|
||||
withStore' $ \db -> setGroupRosterVersion db groupId rosterVer
|
||||
-- update the owner-controlled link version anchor (item 8)
|
||||
gLink_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupLink db user gInfo)
|
||||
forM_ gLink_ $ setGroupLinkDataAsync user gInfo
|
||||
|
||||
-- | Owner: send the current roster (no version bump) to a newly added relay so
|
||||
-- it can serve joiners. No-op if no roster has been published yet.
|
||||
-- Send the current roster (no version bump) to a newly added relay so it can serve joiners.
|
||||
sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
sendGroupRosterToRelay user gInfo relayMember = do
|
||||
vr <- chatVersionRange
|
||||
withStore' (\db -> getGroupRosterVersion db (groupId' gInfo)) >>= \case
|
||||
Nothing -> pure ()
|
||||
Just rosterVer -> do
|
||||
roster <- withStore' $ \db -> buildGroupRoster db vr user gInfo rosterVer
|
||||
void $ sendGroupMessage' user gInfo [relayMember] (XGrpRoster roster)
|
||||
sendGroupRosterToRelay user gInfo relayMember =
|
||||
forM_ (rosterVersion gInfo) $ \rosterVer -> do
|
||||
vr <- chatVersionRange
|
||||
mods <- withStore' $ \db -> getGroupModerators db vr user gInfo
|
||||
void $ sendGroupMessage' user gInfo [relayMember] (XGrpRoster (buildGroupRoster rosterVer mods))
|
||||
|
||||
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
|
||||
sendGroupMessages user gInfo scope asGroup members events = do
|
||||
|
||||
@@ -1344,13 +1344,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
CFSetShortLink ->
|
||||
case (ucGroupId_, auData) of
|
||||
(Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do
|
||||
(gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do
|
||||
(gInfo, gLink, relays, relaysChanged, newlyActiveLinks, newlyActiveGMIds) <- withStore $ \db -> do
|
||||
gInfo <- getGroupInfo db vr user groupId
|
||||
gLink <- getGroupLink db user gInfo
|
||||
relays <- liftIO $ getGroupRelays db gInfo
|
||||
(relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays
|
||||
(relays', changed, newlyActiveLinks, newlyActiveGMIds) <- liftIO $ foldrM (updateRelay db) ([], False, [], []) relays
|
||||
liftIO $ setGroupInProgressDone db gInfo
|
||||
pure (gInfo, gLink, relays', changed, newlyActive)
|
||||
pure (gInfo, gLink, relays', changed, newlyActiveLinks, newlyActiveGMIds)
|
||||
toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged
|
||||
let GroupSummary {publicMemberCount} = groupSummary gInfo
|
||||
-- Owner is counted in publicMemberCount; > 1 means at least one subscriber.
|
||||
@@ -1367,21 +1367,18 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
events = XGrpRelayNew <$> newlyActive
|
||||
unless (null recipients) $
|
||||
void $ sendGroupMessages user gInfo Nothing False recipients events
|
||||
-- A relay that just became active must get the current roster so
|
||||
-- it can serve joiners (item 2b: relay-add). No-op if no roster yet.
|
||||
forM_ (L.nonEmpty newlyActiveLinks) $ \_ -> do
|
||||
relayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo
|
||||
let newRelays = filter (\GroupMember {relayLink} -> maybe False (`elem` newlyActiveLinks) relayLink) relayMembers
|
||||
forM_ newRelays $ \relayMember -> sendGroupRosterToRelay user gInfo relayMember `catchAllErrors` eToView
|
||||
-- send the current roster to relays that just became active so they can serve joiners
|
||||
forM_ newlyActiveGMIds $ \gmId ->
|
||||
(withStore (\db -> getGroupMemberById db vr user gmId) >>= sendGroupRosterToRelay user gInfo) `catchAllErrors` eToView
|
||||
where
|
||||
updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact])
|
||||
updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActive) =
|
||||
updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId])
|
||||
updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) =
|
||||
case relayLink of
|
||||
Just rLink
|
||||
| rLink `elem` relayLinks && relayStatus == RSAccepted -> do
|
||||
relay' <- updateRelayStatus db relay RSActive
|
||||
pure (relay' : acc, True, rLink : newlyActive)
|
||||
| rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActive)
|
||||
pure (relay' : acc, True, rLink : newlyActiveLinks, groupMemberId : newlyActiveGMIds)
|
||||
| rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds)
|
||||
| relayStatus == RSActive -> do
|
||||
-- Relay link absent from link data — deactivate.
|
||||
-- RSAccepted relays are not deactivated: their own link data update
|
||||
@@ -1390,8 +1387,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
-- TODO the SMP server, but this owner won't receive a LINK callback for it
|
||||
-- TODO (LINK only fires in response to own setConnShortLink calls).
|
||||
relay' <- updateRelayStatus db relay RSInactive
|
||||
pure (relay' : acc, True, newlyActive)
|
||||
_ -> pure (relay : acc, changed, newlyActive)
|
||||
pure (relay' : acc, True, newlyActiveLinks, newlyActiveGMIds)
|
||||
_ -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds)
|
||||
_ -> throwChatError $ CECommandError "LINK event expected for a group link only"
|
||||
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
|
||||
MERR _ err -> do
|
||||
@@ -1659,21 +1656,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
pure True
|
||||
else pure False
|
||||
|
||||
-- Relay: when a subscriber's queue drains and delivery resumes, forward the
|
||||
-- current cached roster ahead of the resumed backlog if the subscriber is
|
||||
-- behind, so it holds the privileged set before processing moderator events
|
||||
-- it would otherwise reject (RGEMsgBadSignature). Gated on the per-subscriber
|
||||
-- delivered_roster_version so it fires only when behind. A roster delivered
|
||||
-- by the ordinary broadcast does not advance the tracker, so at most one
|
||||
-- redundant (idempotent) roster send can occur per resume.
|
||||
-- Relay: on a subscriber's queue draining, forward the cached roster ahead of
|
||||
-- the resumed backlog, so it holds the privileged set before the events it verifies.
|
||||
sendRosterCatchUp :: GroupInfo -> GroupMember -> CM ()
|
||||
sendRosterCatchUp gInfo m = when (isUserGrpFwdRelay gInfo) $ do
|
||||
cur <- withStore' $ \db -> getGroupRosterVersion db (groupId' gInfo)
|
||||
forM_ cur $ \c -> do
|
||||
delivered <- withStore' $ \db -> getDeliveredRosterVersion db (groupMemberId' m)
|
||||
when (maybe True (< c) delivered) $ do
|
||||
forwardCachedRosterToMember user gInfo m
|
||||
withStore' $ \db -> setDeliveredRosterVersion db (groupMemberId' m) c
|
||||
sendRosterCatchUp gInfo m = when (isUserGrpFwdRelay gInfo) $ forwardCachedRoster user gInfo m
|
||||
|
||||
-- TODO v5.7 / v6.0 - together with deprecating old group protocol establishing direct connections?
|
||||
-- we could save command records only for agent APIs we process continuations for (INV)
|
||||
@@ -2982,29 +2968,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do
|
||||
let fromRelay = useRelays' gInfo && isRelay m
|
||||
-- The relay no longer gates privileged dissemination; for a privileged
|
||||
-- role we instead require a roster-established record (see below). For
|
||||
-- non-relay groups the sender must still be an admin to introduce.
|
||||
unless fromRelay $ checkHostRole m memRole
|
||||
if sameMemberId memId (membership gInfo)
|
||||
then pure Nothing
|
||||
else
|
||||
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case
|
||||
-- privileged members are roster-established; the relay only disseminates
|
||||
-- their profile, never their key/role (those come from the owner roster)
|
||||
Right unknownMember@GroupMember {memberStatus = GSMemUnknown}
|
||||
-- Privileged role: key/role are owner-established by the roster.
|
||||
-- Accept this dissemination only as a PROFILE update for a member the
|
||||
-- roster already established with that role; never set key/role here.
|
||||
| fromRelay && isRosterRole memRole ->
|
||||
if memberRole' unknownMember == memRole
|
||||
then announceUpdate unknownMember $ \db -> updateRosterMemberProfileAnnounced db vr user m unknownMember memInfo initialStatus
|
||||
then do
|
||||
updatedMember <- withStore $ \db -> updateRosterMemberProfileAnnounced db vr user m unknownMember memInfo initialStatus
|
||||
announceUnknownMember unknownMember updatedMember
|
||||
else messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing
|
||||
| otherwise -> announceUpdate unknownMember $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus
|
||||
Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do
|
||||
(updatedMember, gInfo') <- withStore $ \db -> do
|
||||
updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus
|
||||
gInfo' <-
|
||||
if memberPending updatedMember
|
||||
then liftIO $ increaseGroupMembersRequireAttention db user gInfo
|
||||
else pure gInfo
|
||||
pure (updatedMember, gInfo')
|
||||
gInfo'' <- updatePublicGroupData user gInfo'
|
||||
toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember
|
||||
memberAnnouncedToView updatedMember gInfo''
|
||||
pure $ deliveryJobScope updatedMember
|
||||
Right _
|
||||
| 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 _
|
||||
-- No record: a privileged member absent from the roster is a relay
|
||||
-- conjuring a moderator — reject. Non-privileged members create as usual.
|
||||
-- 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
|
||||
| otherwise -> do
|
||||
(newMember, gInfo') <- withStore $ \db -> do
|
||||
@@ -3018,15 +3012,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
memberAnnouncedToView newMember gInfo''
|
||||
pure $ deliveryJobScope newMember
|
||||
where
|
||||
announceUpdate unknownMember updateFn = do
|
||||
(updatedMember, gInfo') <- withStore $ \db -> do
|
||||
updatedMember <- updateFn db
|
||||
gInfo' <-
|
||||
if memberPending updatedMember
|
||||
then liftIO $ increaseGroupMembersRequireAttention db user gInfo
|
||||
else pure gInfo
|
||||
pure (updatedMember, gInfo')
|
||||
gInfo'' <- updatePublicGroupData user gInfo'
|
||||
-- roster members can't be pending, so no members-require-attention update
|
||||
announceUnknownMember unknownMember updatedMember = do
|
||||
gInfo'' <- updatePublicGroupData user gInfo
|
||||
toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember
|
||||
memberAnnouncedToView updatedMember gInfo''
|
||||
pure $ deliveryJobScope updatedMember
|
||||
@@ -3067,10 +3055,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
messageError "x.grp.mem.intro ignored: member already exists"
|
||||
Left _
|
||||
| useRelays' gInfo -> do
|
||||
-- Privileged keys (owner from the link, moderator/admin from the
|
||||
-- owner-signed roster) must never come from a relay intro. Drop the
|
||||
-- relay-asserted key for any role above member; the roster (forwarded
|
||||
-- on join before intros) is the trust path for moderators/admins.
|
||||
-- drop the relay-asserted key for privileged roles; their keys come from the roster, not intros
|
||||
let memInfo' = case memInfo of
|
||||
MemberInfo mId mRole v p _
|
||||
| mRole > GRMember -> MemberInfo mId mRole v p Nothing
|
||||
@@ -3165,16 +3150,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned}
|
||||
pure $ memberEventDeliveryScope member
|
||||
|
||||
-- Owner-signed privileged roster. Returns the broadcast scope for the relay
|
||||
-- (which forwards the verbatim signed roster to current members); the member
|
||||
-- path discards it. The relay also reaches this via the main dispatch; the
|
||||
-- member reaches it via the forwarded dispatch (xGrpMsgForward).
|
||||
xGrpRoster :: MsgEncodingI e => GroupInfo -> GroupMember -> GroupRoster -> VerifiedMsg e -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpRoster gInfo author roster verifiedMsg brokerTs
|
||||
-- CRUX: only an owner (whose key comes from the link, never the relay) may
|
||||
-- sign a roster. The relay chooses fwdSender / the connection sender, so
|
||||
-- without this assertion a relay could route a roster as a member whose key
|
||||
-- it controls and the signature would verify against that fabricated member.
|
||||
-- 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
|
||||
| isUserGrpFwdRelay gInfo = relayApplyRoster
|
||||
| otherwise = Nothing <$ memberApplyRoster
|
||||
@@ -3183,58 +3161,42 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
validRoster = validateGroupRoster roster
|
||||
groupId = groupId' gInfo
|
||||
relayApplyRoster :: CM (Maybe DeliveryJobScope)
|
||||
relayApplyRoster = do
|
||||
cached <- withStore' $ \db -> getGroupRosterVersion db groupId
|
||||
case cached of
|
||||
Just c | newVer <= c -> pure Nothing -- rollback (lower) or duplicate (equal): no-op
|
||||
_ -> case signedMsgBytes of
|
||||
Nothing -> messageError "x.grp.roster: relay received unsigned roster" $> Nothing
|
||||
Just bytes -> do
|
||||
withStore' $ \db -> setCachedGroupRoster db groupId newVer bytes
|
||||
applyRosterRecords False -- relay: key apply is authoritative, no UI surface
|
||||
-- Always broadcast on a strict version bump: self-healing, and
|
||||
-- demotions (drop from roster) must reach members. Driven here
|
||||
-- because this is the only moment the relay holds the new roster.
|
||||
relayApplyRoster
|
||||
| maybe False (newVer <=) (rosterVersion gInfo) = Nothing <$ messageWarning "x.grp.roster: not newer than cached version"
|
||||
| otherwise = case verifiedMsg of
|
||||
VMSigned _ sm _ -> do
|
||||
withStore' $ \db -> setCachedGroupRoster db groupId newVer (groupMemberId' author) brokerTs sm
|
||||
applyRosterRecords
|
||||
-- always broadcast on a bump: self-healing, and demotions must reach members
|
||||
pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}
|
||||
VMUnsigned _ -> Nothing <$ messageWarning "x.grp.roster: unsigned roster"
|
||||
memberApplyRoster :: CM ()
|
||||
memberApplyRoster = do
|
||||
highest <- withStore' $ \db -> getGroupRosterVersion db groupId
|
||||
case highest of
|
||||
Just h | newVer < h -> messageError "x.grp.roster: version older than accepted (replay)"
|
||||
_ -> do
|
||||
applyRosterRecords True -- member: trust-on-first-use, surface key conflicts
|
||||
memberApplyRoster
|
||||
| maybe False (newVer <) (rosterVersion gInfo) = messageWarning "x.grp.roster: older than accepted version"
|
||||
| otherwise = do
|
||||
applyRosterRecords
|
||||
withStore' $ \db -> setGroupRosterVersion db groupId newVer
|
||||
applyRosterRecords :: Bool -> CM ()
|
||||
applyRosterRecords tofu = do
|
||||
applyRosterRecords :: CM ()
|
||||
applyRosterRecords = do
|
||||
let GroupRoster {roster = entries} = validRoster
|
||||
rosterIds = map (\RosterMember {memberId} -> memberId) entries
|
||||
mapM_ (applyRosterEntry tofu) entries
|
||||
-- latest-wins: members formerly privileged but absent from the new
|
||||
-- roster revert to the joiner default.
|
||||
-- TODO [section 2] replace unknownMemberRole with joinerRoleFor when added.
|
||||
mapM_ applyRosterEntry entries
|
||||
-- absent privileged members revert to the joiner default
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
currentPriv <- withStore' $ \db -> getGroupModerators db vr user gInfo
|
||||
forM_ currentPriv $ \m ->
|
||||
when (isRosterRole (memberRole' m) && memberId' m `notElem` rosterIds) $
|
||||
withStore' $ \db -> updateGroupMemberRole db user m defaultRole
|
||||
applyRosterEntry :: Bool -> RosterMember -> CM ()
|
||||
applyRosterEntry tofu RosterMember {memberId, name, key = MemberKey pubKey, role} =
|
||||
applyRosterEntry :: RosterMember -> CM ()
|
||||
applyRosterEntry RosterMember {memberId, name, key = MemberKey pubKey, role} =
|
||||
withStore' (\db -> runExceptT $ getCreateUnknownGMByMemberId db vr user gInfo memberId name role True) >>= \case
|
||||
Right (Just (m@GroupMember {groupMemberId}, _created)) ->
|
||||
case memberPubKey m of
|
||||
Just k
|
||||
| k == pubKey -> setRole m
|
||||
| tofu -> surfaceSuspiciousRosterKey m -- reject entry, keep the pinned key
|
||||
| otherwise -> setKey groupMemberId >> setRole m -- relay: authoritative overwrite
|
||||
Nothing -> setKey groupMemberId >> setRole m -- first sight (TOFU pin)
|
||||
where
|
||||
setKey gmId = withStore' $ \db -> setGroupMemberPubKey db gmId pubKey
|
||||
setRole m' = unless (memberRole' m' == role) $ withStore' $ \db -> updateGroupMemberRole db user m' role
|
||||
| k == pubKey -> unless (memberRole' m == role) $ withStore' $ \db -> updateGroupMemberRole db user m role
|
||||
| otherwise -> messageWarning "x.grp.roster: member key conflict, keeping trusted key"
|
||||
Nothing -> withStore' $ \db -> setGroupMemberKeyRole db groupMemberId pubKey role
|
||||
_ -> messageError "x.grp.roster: could not get or create member record"
|
||||
surfaceSuspiciousRosterKey :: GroupMember -> CM ()
|
||||
surfaceSuspiciousRosterKey m = do
|
||||
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
|
||||
void $ createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvGroupEvent RGESuspiciousRosterKey) (Just brokerTs)
|
||||
signedMsgBytes :: Maybe ByteString
|
||||
signedMsgBytes = case verifiedMsg of
|
||||
VMSigned _ sm _ -> Just $ smpEncode sm
|
||||
@@ -3868,10 +3830,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
|
||||
if null senders
|
||||
then pure (body, [], [], [])
|
||||
else do
|
||||
-- All members' profiles disseminate, including moderators/admins:
|
||||
-- their key/role is owner-established via the roster (xGrpMemNew
|
||||
-- accepts a privileged XGrpMemNew only as a profile update for a
|
||||
-- roster-established member), so the profile sidecar is safe.
|
||||
-- all members' profiles disseminate; privileged key/role come from the roster, not here
|
||||
let (encoderErrs, validLabeled) =
|
||||
partitionEithers
|
||||
[ (\bs -> (s, bs)) <$> encodeMemberNew vr gInfo s
|
||||
|
||||
@@ -245,7 +245,6 @@ ciRequiresAttention content = case msgDirection @d of
|
||||
RGEMemberProfileUpdated {} -> False
|
||||
RGENewMemberPendingReview -> True
|
||||
RGEMsgBadSignature -> False
|
||||
RGESuspiciousRosterKey -> False
|
||||
CIRcvConnEvent _ -> True
|
||||
CIRcvChatFeature {} -> False
|
||||
CIRcvChatPreference {} -> False
|
||||
@@ -375,7 +374,6 @@ rcvGroupEventToText = \case
|
||||
RGEMemberProfileUpdated {} -> "updated profile"
|
||||
RGENewMemberPendingReview -> "new member wants to join the group"
|
||||
RGEMsgBadSignature -> "message rejected: bad signature"
|
||||
RGESuspiciousRosterKey -> "roster gave a different key for a member: kept the previously trusted key"
|
||||
|
||||
sndGroupEventToText :: SndGroupEvent -> Text
|
||||
sndGroupEventToText = \case
|
||||
|
||||
@@ -33,7 +33,6 @@ data RcvGroupEvent
|
||||
| RGEMemberProfileUpdated {fromProfile :: Profile, toProfile :: Profile} -- CRGroupMemberUpdated
|
||||
| RGENewMemberPendingReview
|
||||
| RGEMsgBadSignature
|
||||
| RGESuspiciousRosterKey -- owner roster gave a different key for a known member; kept the pinned key (TOFU)
|
||||
deriving (Show)
|
||||
|
||||
data SndGroupEvent
|
||||
|
||||
@@ -353,28 +353,22 @@ data GrpMsgForward = GrpMsgForward
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Owner-signed snapshot of the privileged (moderator/admin) set for a
|
||||
-- relay-mediated group. Owners are never included (their keys come from the
|
||||
-- link). 'version' is monotonic from 0; recipients treat the highest valid
|
||||
-- version as authoritative (latest-wins, see Subscriber.xGrpRoster).
|
||||
-- | Owner-signed snapshot of the privileged (moderator/admin) set; owners are
|
||||
-- not included, their keys come from the link.
|
||||
data GroupRoster = GroupRoster
|
||||
{ version :: Word32,
|
||||
{ version :: Int,
|
||||
roster :: [RosterMember]
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | One privileged member in a roster. 'name' is display-only (avoids "unknown
|
||||
-- member" rows). 'role' is validated to {GRModerator, GRAdmin} on receipt
|
||||
-- (validateGroupRoster); the key is trust-on-first-use pinned per memberId.
|
||||
data RosterMember = RosterMember
|
||||
{ memberId :: MemberId,
|
||||
name :: Text,
|
||||
key :: MemberKey,
|
||||
key :: MemberKey, -- trust-on-first-use pinned per memberId
|
||||
role :: GroupMemberRole
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
|
||||
instance Encoding FwdSender where
|
||||
smpEncode = \case
|
||||
FwdMember memberId memberName -> smpEncode ('M', memberId, memberName)
|
||||
@@ -796,8 +790,6 @@ newtype MsgMentions = MsgMentions (Map MemberName MsgMention)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''RosterMember)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''GroupRoster)
|
||||
|
||||
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''LinkOwnerSig)
|
||||
@@ -898,12 +890,7 @@ maxCompressedMsgLength = 13380
|
||||
maxDecompressedMsgLength :: Int
|
||||
maxDecompressedMsgLength = 65536
|
||||
|
||||
-- | Cap on the privileged set (moderators + admins) so the owner-signed roster
|
||||
-- (XGrpRoster) always fits one message and never paginates (Section 1.4).
|
||||
-- A worst-case compact {memberId, name, key, role} entry is ~215 B JSON;
|
||||
-- (maxEncodedMsgLength minus signature + wrapper overhead) / 215 leaves ample
|
||||
-- margin at 64. Enforced at promotion time in APIMembersRole. Owners are not
|
||||
-- counted (their keys come from the link, not the roster).
|
||||
-- Bound on moderators + admins so the signed roster always fits one message.
|
||||
maxGroupRosterSize :: Int
|
||||
maxGroupRosterSize = 64
|
||||
|
||||
@@ -1379,7 +1366,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
|
||||
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
|
||||
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
|
||||
XGrpRoster_ -> XGrpRoster <$> JT.parseEither parseJSON (J.Object params)
|
||||
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "roster")
|
||||
XGrpMsgForward_ -> do
|
||||
fwdSender <- opt "memberId" >>= \case
|
||||
Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName"
|
||||
@@ -1451,9 +1438,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XGrpInfo p -> o ["groupProfile" .= p]
|
||||
XGrpPrefs p -> o ["groupPreferences" .= p]
|
||||
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
|
||||
XGrpRoster gr -> case toJSON gr of
|
||||
J.Object obj -> obj
|
||||
_ -> JM.empty
|
||||
XGrpRoster GroupRoster {version, roster} -> o ["version" .= version, "roster" .= roster]
|
||||
XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs]
|
||||
where
|
||||
encodeFwdSender = \case
|
||||
@@ -1510,11 +1495,7 @@ data ContactShortLinkData = ContactShortLinkData
|
||||
deriving (Show)
|
||||
|
||||
data PublicGroupData = PublicGroupData
|
||||
{ publicMemberCount :: Int64,
|
||||
-- | Current privileged-roster version, anchored in owner-controlled link
|
||||
-- data so a joiner can detect a relay serving a stale roster. Optional for
|
||||
-- backward compatibility (omitNothingFields); detection only, not a gate.
|
||||
rosterVersion :: Maybe Word32
|
||||
{ publicMemberCount :: Int64
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
|
||||
@@ -84,13 +84,10 @@ module Simplex.Chat.Store.Groups
|
||||
getGroupRelayByGMId,
|
||||
getGroupRelays,
|
||||
getConnectedGroupRelays,
|
||||
getGroupRosterVersion,
|
||||
setGroupRosterVersion,
|
||||
setCachedGroupRoster,
|
||||
getCachedGroupRoster,
|
||||
getDeliveredRosterVersion,
|
||||
setDeliveredRosterVersion,
|
||||
setGroupMemberPubKey,
|
||||
setGroupMemberKeyRole,
|
||||
createRelayForOwner,
|
||||
getCreateRelayForMember,
|
||||
createRelayConnection,
|
||||
@@ -219,6 +216,7 @@ import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Types.UITheme
|
||||
import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), UserId)
|
||||
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow)
|
||||
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
|
||||
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
|
||||
import Simplex.Messaging.Agent.Store.Entity (DBEntityId)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
@@ -422,6 +420,7 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_},
|
||||
rosterVersion = Nothing,
|
||||
customData = Nothing,
|
||||
membersRequireAttention = 0,
|
||||
viaGroupLinkUri = Nothing,
|
||||
@@ -499,6 +498,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
groupSummary = GroupSummary {currentMembers = 2, publicMemberCount = Nothing},
|
||||
rosterVersion = Nothing,
|
||||
customData = Nothing,
|
||||
membersRequireAttention = 0,
|
||||
viaGroupLinkUri = Nothing,
|
||||
@@ -1381,64 +1381,44 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f
|
||||
let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted}
|
||||
in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink}
|
||||
|
||||
-- | Privileged-roster cache (M20260526). 'roster_version' is this client's
|
||||
-- current version: owner's current / relay's cached / member's highest-accepted
|
||||
-- (one role per client per group). 'roster_msg' (relay only) is the verbatim
|
||||
-- owner-signed roster message (smpEncode'd SignedMsg), re-emitted to joiners.
|
||||
|
||||
getGroupRosterVersion :: DB.Connection -> GroupId -> IO (Maybe Word32)
|
||||
getGroupRosterVersion db groupId =
|
||||
toRosterVersion <$> maybeFirstRow fromOnly (DB.query db "SELECT roster_version FROM groups WHERE group_id = ?" (Only groupId))
|
||||
|
||||
toRosterVersion :: Maybe (Maybe Int64) -> Maybe Word32
|
||||
toRosterVersion = fmap fromIntegral . join
|
||||
|
||||
-- Member side: record highest-accepted version without caching bytes (members do not re-forward).
|
||||
setGroupRosterVersion :: DB.Connection -> GroupId -> Word32 -> IO ()
|
||||
setGroupRosterVersion :: DB.Connection -> GroupId -> Int -> IO ()
|
||||
setGroupRosterVersion db groupId v = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?"
|
||||
(fromIntegral v :: Int64, currentTs, groupId)
|
||||
DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId)
|
||||
|
||||
-- Relay/owner side: cache the verbatim signed roster bytes alongside the version.
|
||||
setCachedGroupRoster :: DB.Connection -> GroupId -> Word32 -> B.ByteString -> IO ()
|
||||
setCachedGroupRoster db groupId v signedMsgBytes = do
|
||||
-- Relay caches the verbatim signed roster (parts + sending owner + broker ts) to re-forward to joiners.
|
||||
setCachedGroupRoster :: DB.Connection -> GroupId -> Int -> GroupMemberId -> UTCTime -> SignedMsg -> IO ()
|
||||
setCachedGroupRoster db groupId v ownerGMId brokerTs SignedMsg {chatBinding, signatures, signedBody} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE groups SET roster_version = ?, roster_msg = ?, updated_at = ? WHERE group_id = ?"
|
||||
(fromIntegral v :: Int64, Binary signedMsgBytes, currentTs, groupId)
|
||||
[sql|
|
||||
UPDATE groups
|
||||
SET roster_version = ?, roster_sending_owner_gm_id = ?, roster_broker_ts = ?,
|
||||
roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?, updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
((v, ownerGMId, brokerTs, chatBinding) :. (Binary (smpEncode signatures), Binary signedBody, currentTs, groupId))
|
||||
|
||||
getCachedGroupRoster :: DB.Connection -> GroupId -> IO (Maybe (Word32, B.ByteString))
|
||||
getCachedGroupRoster :: DB.Connection -> GroupId -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg))
|
||||
getCachedGroupRoster db groupId =
|
||||
(>>= toRoster)
|
||||
<$> maybeFirstRow id (DB.query db "SELECT roster_version, roster_msg FROM groups WHERE group_id = ?" (Only groupId))
|
||||
<$> maybeFirstRow
|
||||
id
|
||||
( DB.query
|
||||
db
|
||||
"SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body FROM groups WHERE group_id = ?"
|
||||
(Only groupId)
|
||||
)
|
||||
where
|
||||
toRoster :: (Maybe Int64, Maybe (Binary B.ByteString)) -> Maybe (Word32, B.ByteString)
|
||||
toRoster (Just v, Just (Binary bs)) = Just (fromIntegral v, bs)
|
||||
toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body)) =
|
||||
(\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body)) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
toRoster _ = Nothing
|
||||
|
||||
getDeliveredRosterVersion :: DB.Connection -> GroupMemberId -> IO (Maybe Word32)
|
||||
getDeliveredRosterVersion db gmId =
|
||||
toRosterVersion <$> maybeFirstRow fromOnly (DB.query db "SELECT delivered_roster_version FROM group_members WHERE group_member_id = ?" (Only gmId))
|
||||
|
||||
setDeliveredRosterVersion :: DB.Connection -> GroupMemberId -> Word32 -> IO ()
|
||||
setDeliveredRosterVersion db gmId v =
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE group_members SET delivered_roster_version = ? WHERE group_member_id = ?"
|
||||
(fromIntegral v :: Int64, gmId)
|
||||
|
||||
-- | Pin a member's public key (roster TOFU first-sight / relay-authoritative).
|
||||
setGroupMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO ()
|
||||
setGroupMemberPubKey db gmId pubKey = do
|
||||
setGroupMemberKeyRole :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> GroupMemberRole -> IO ()
|
||||
setGroupMemberKeyRole db gmId pubKey role = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?"
|
||||
(pubKey, currentTs, gmId)
|
||||
DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, gmId)
|
||||
|
||||
createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember
|
||||
createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do
|
||||
@@ -3213,10 +3193,8 @@ updateUnknownMemberAnnounced db vr user@User {userId} invitingMember unknownMemb
|
||||
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
|
||||
memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey
|
||||
|
||||
-- | Like updateUnknownMemberAnnounced, but preserves member_role and
|
||||
-- member_pub_key. Used for roster-established moderators/admins: their role and
|
||||
-- key are owner-authoritative (from the signed roster) and must never be
|
||||
-- overwritten by a relay-disseminated XGrpMemNew, which only carries the profile.
|
||||
-- Like updateUnknownMemberAnnounced but preserves member_role and member_pub_key
|
||||
-- (roster-established for moderators/admins; the dissemination carries only the profile).
|
||||
updateRosterMemberProfileAnnounced :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember
|
||||
updateRosterMemberProfileAnnounced db vr user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} status = do
|
||||
_ <- updateMemberProfile db user unknownMember profile
|
||||
|
||||
@@ -9,17 +9,21 @@ import Text.RawString.QQ (r)
|
||||
m20260526_group_roster :: Text
|
||||
m20260526_group_roster =
|
||||
[r|
|
||||
ALTER TABLE groups ADD COLUMN roster_version BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg BYTEA;
|
||||
|
||||
ALTER TABLE group_members ADD COLUMN delivered_roster_version BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_version SMALLINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_body BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_signatures BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_broker_ts TIMESTAMPTZ;
|
||||
|]
|
||||
|
||||
down_m20260526_group_roster :: Text
|
||||
down_m20260526_group_roster =
|
||||
[r|
|
||||
ALTER TABLE group_members DROP COLUMN delivered_roster_version;
|
||||
|
||||
ALTER TABLE groups DROP COLUMN roster_msg;
|
||||
ALTER TABLE groups DROP COLUMN roster_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_version;
|
||||
|]
|
||||
|
||||
@@ -9,16 +9,20 @@ m20260526_group_roster :: Query
|
||||
m20260526_group_roster =
|
||||
[sql|
|
||||
ALTER TABLE groups ADD COLUMN roster_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg BLOB;
|
||||
|
||||
ALTER TABLE group_members ADD COLUMN delivered_roster_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_body BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_msg_signatures BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_broker_ts TEXT;
|
||||
|]
|
||||
|
||||
down_m20260526_group_roster :: Query
|
||||
down_m20260526_group_roster =
|
||||
[sql|
|
||||
ALTER TABLE group_members DROP COLUMN delivered_roster_version;
|
||||
|
||||
ALTER TABLE groups DROP COLUMN roster_msg;
|
||||
ALTER TABLE groups DROP COLUMN roster_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_version;
|
||||
|]
|
||||
|
||||
@@ -180,7 +180,11 @@ CREATE TABLE groups(
|
||||
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||
relay_inactive_at TEXT,
|
||||
roster_version INTEGER,
|
||||
roster_msg BLOB, -- received
|
||||
roster_msg_body BLOB,
|
||||
roster_msg_chat_binding TEXT,
|
||||
roster_msg_signatures BLOB,
|
||||
roster_sending_owner_gm_id INTEGER,
|
||||
roster_broker_ts TEXT, -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -225,7 +229,6 @@ CREATE TABLE group_members(
|
||||
relay_link BLOB,
|
||||
member_pub_key BLOB,
|
||||
removed_at TEXT,
|
||||
delivered_roster_version INTEGER,
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -665,14 +665,14 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member
|
||||
|
||||
type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519)
|
||||
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe Int, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
|
||||
|
||||
type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
|
||||
|
||||
type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences)
|
||||
|
||||
toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
|
||||
let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
@@ -682,7 +682,7 @@ toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName,
|
||||
businessChat = toBusinessChatInfo businessRow
|
||||
preparedGroup = toPreparedGroup preparedGroupRow
|
||||
groupSummary = GroupSummary {currentMembers, publicMemberCount}
|
||||
in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys}
|
||||
in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys}
|
||||
|
||||
toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup
|
||||
toPreparedGroup = \case
|
||||
@@ -765,7 +765,7 @@ groupInfoQueryFields =
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
g.business_chat, g.business_member_id, g.customer_member_id,
|
||||
g.use_relays, g.relay_own_status,
|
||||
g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
|
||||
g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
|
||||
g.root_priv_key, g.root_pub_key, g.member_priv_key,
|
||||
-- GroupMember - membership
|
||||
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
|
||||
|
||||
@@ -487,6 +487,7 @@ data GroupInfo = GroupInfo
|
||||
uiThemes :: Maybe UIThemeEntityOverrides,
|
||||
customData :: Maybe CustomData,
|
||||
groupSummary :: GroupSummary,
|
||||
rosterVersion :: Maybe Int,
|
||||
membersRequireAttention :: Int,
|
||||
viaGroupLinkUri :: Maybe ConnReqContact,
|
||||
groupKeys :: Maybe GroupKeys
|
||||
|
||||
@@ -9533,8 +9533,12 @@ testChannelModeratorActionViaRoster ps =
|
||||
cath ##> "/block for all #team dan"
|
||||
cath <## "#team: you blocked dan (signed)"
|
||||
bob <## "#team: cath blocked dan (signed)"
|
||||
-- eve learned cath (name, key, moderator role) only from the roster;
|
||||
-- cath's profile arrives with the forwarded block, then the block
|
||||
-- verifies against cath's roster key. "(signed)" => eve verified it.
|
||||
eve <## "#team: unknown member cath updated to cath"
|
||||
eve <## "#team: bob introduced cath (Catherine) in the channel"
|
||||
eve <## "#team: cath blocked dan (signed)"
|
||||
eve #$> ("/_get chat #1 count=1", chat, [(0, "blocked dan (signed)")])
|
||||
|
||||
testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRemoveMemberSigned ps =
|
||||
|
||||
Reference in New Issue
Block a user