mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 22:48:18 +00:00
core: roster delivery via inline file (#7048)
This commit is contained in:
@@ -83,6 +83,7 @@ This file is generated automatically.
|
||||
- [FileProtocol](#fileprotocol)
|
||||
- [FileStatus](#filestatus)
|
||||
- [FileTransferMeta](#filetransfermeta)
|
||||
- [FileType](#filetype)
|
||||
- [Format](#format)
|
||||
- [FormattedText](#formattedtext)
|
||||
- [FullGroupPreferences](#fullgrouppreferences)
|
||||
@@ -2051,6 +2052,15 @@ NO_FILE:
|
||||
- cancelled: bool
|
||||
|
||||
|
||||
---
|
||||
|
||||
## FileType
|
||||
|
||||
**Enum type**:
|
||||
- "normal"
|
||||
- "roster"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Format
|
||||
@@ -3258,6 +3268,7 @@ Cancelled:
|
||||
- xftpRcvFile: [XFTPRcvFile](#xftprcvfile)?
|
||||
- fileInvitation: [FileInvitation](#fileinvitation)
|
||||
- fileStatus: [RcvFileStatus](#rcvfilestatus)
|
||||
- fileType: [FileType](#filetype)
|
||||
- rcvFileInline: [InlineFileMode](#inlinefilemode)?
|
||||
- senderDisplayName: string
|
||||
- chunkSize: int64
|
||||
|
||||
@@ -265,6 +265,7 @@ chatTypesDocsData =
|
||||
(sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""),
|
||||
(sti @FileStatus, STEnum, "FS", [], "", ""),
|
||||
(sti @FileTransferMeta, STRecord, "", [], "", ""),
|
||||
(sti @FileType, STEnum' (consLower "FT"), "", [], "", ""),
|
||||
(sti @Format, STUnion, "", ["Unknown"], "", ""),
|
||||
(sti @FormattedText, STRecord, "", [], "", ""),
|
||||
(sti @FullGroupPreferences, STRecord, "", [], "", ""),
|
||||
@@ -480,6 +481,7 @@ deriving instance Generic FileInvitation
|
||||
deriving instance Generic FileProtocol
|
||||
deriving instance Generic FileStatus
|
||||
deriving instance Generic FileTransferMeta
|
||||
deriving instance Generic FileType
|
||||
deriving instance Generic Format
|
||||
deriving instance Generic FormattedText
|
||||
deriving instance Generic FullGroupPreferences
|
||||
|
||||
@@ -182,7 +182,7 @@ The low-level protocol supports multiple owners from the initial release. The ap
|
||||
|
||||
- **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights.
|
||||
|
||||
Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol.
|
||||
Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. The owner-signed roster tracks the promoted set - members, moderators, and admins; subscribers are observers until an owner promotes them.
|
||||
|
||||
For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md).
|
||||
|
||||
@@ -242,6 +242,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/
|
||||
- Undetectably substitute content - subscribers on honest relays receive the original.
|
||||
- Alter the channel's authoritative state on the owner's device.
|
||||
- Substitute the channel profile or impersonate an owner - these require valid signatures.
|
||||
- Replay an old roster or role change to re-elevate a removed or demoted member for existing subscribers - they reject anything older than the roster version they applied (a new joiner with no prior roster can still be served an old one, until it syncs from another relay).
|
||||
- Redirect subscribers to a different channel - the entity ID is validated across link and profile.
|
||||
- Determine subscriber identity or network address - inherited from SMP transport.
|
||||
- Correlate subscriber participation across channels - each connection uses independent SMP queues. The subscriber chooses their SMP router independently, so collusion between a relay and the relay's SMP router does not compromise connections through a different router.
|
||||
|
||||
@@ -2341,6 +2341,11 @@ export interface FileTransferMeta {
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export enum FileType {
|
||||
Normal = "normal",
|
||||
Roster = "roster",
|
||||
}
|
||||
|
||||
export type Format =
|
||||
| Format.Bold
|
||||
| Format.Italic
|
||||
@@ -3601,6 +3606,7 @@ export interface RcvFileTransfer {
|
||||
xftpRcvFile?: XFTPRcvFile
|
||||
fileInvitation: FileInvitation
|
||||
fileStatus: RcvFileStatus
|
||||
fileType: FileType
|
||||
rcvFileInline?: InlineFileMode
|
||||
senderDisplayName: string
|
||||
chunkSize: number // int64
|
||||
|
||||
@@ -1646,6 +1646,8 @@ class FileTransferMeta(TypedDict):
|
||||
chunkSize: int # int64
|
||||
cancelled: bool
|
||||
|
||||
FileType = Literal["normal", "roster"]
|
||||
|
||||
class Format_bold(TypedDict):
|
||||
type: Literal["bold"]
|
||||
|
||||
@@ -2529,6 +2531,7 @@ class RcvFileTransfer(TypedDict):
|
||||
xftpRcvFile: NotRequired["XFTPRcvFile"]
|
||||
fileInvitation: "FileInvitation"
|
||||
fileStatus: "RcvFileStatus"
|
||||
fileType: "FileType"
|
||||
rcvFileInline: NotRequired["InlineFileMode"]
|
||||
senderDisplayName: str
|
||||
chunkSize: int # int64
|
||||
|
||||
@@ -1270,6 +1270,8 @@ processChatCommand cxt nm = \case
|
||||
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
|
||||
withGroupLock "deleteChat group" chatId $ do
|
||||
deleteCIFiles user filesInfo
|
||||
-- the roster blob file has no chat item, so it is missed by getGroupFileInfo above
|
||||
cleanupGroupRosterFile user gInfo
|
||||
(members, recipients) <- getRecipients gInfo
|
||||
let doSendDel = memberActive membership && isOwner
|
||||
msgSigned <-
|
||||
@@ -2022,9 +2024,9 @@ processChatCommand cxt nm = \case
|
||||
gVar <- asks random
|
||||
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing
|
||||
hostMember <- maybe (throwCmdError "no host member") pure hostMember_
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let cd = CDGroupRcv gInfo Nothing hostMember
|
||||
createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing
|
||||
createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing Nothing
|
||||
cInfo = GroupChat gInfo Nothing
|
||||
void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo
|
||||
aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message
|
||||
@@ -2034,9 +2036,9 @@ processChatCommand cxt nm = \case
|
||||
pure $ CRNewPreparedChat user $ AChat SCTGroup chat
|
||||
ACCL _ (CCLink cReq _) -> do
|
||||
ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let cd = CDDirectRcv ct
|
||||
createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing
|
||||
createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing
|
||||
cInfo = DirectChat ct
|
||||
void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq
|
||||
void $ createFeatureEnabledItems_ user ct
|
||||
@@ -2053,11 +2055,11 @@ processChatCommand cxt nm = \case
|
||||
subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember
|
||||
gVar <- asks random
|
||||
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_
|
||||
cInfo = GroupChat gInfo Nothing
|
||||
void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo
|
||||
aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing
|
||||
aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing Nothing
|
||||
let chat = case aci of
|
||||
Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci}
|
||||
_ -> Chat cInfo [] emptyChatStats
|
||||
@@ -2125,7 +2127,7 @@ processChatCommand cxt nm = \case
|
||||
-- create changed feature items (connecting incognito sends default preferences, instead of user preferences)
|
||||
lift . when incognito $ createContactChangedFeatureItems user ct ct'
|
||||
forM_ msg_ $ \(sharedMsgId, mc) -> do
|
||||
ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing
|
||||
ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
|
||||
toView $ CEvtNewChatItems user [ci]
|
||||
pure $ CRStartedConnectionToContact user ct' customUserProfile
|
||||
CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct'
|
||||
@@ -2218,7 +2220,7 @@ processChatCommand cxt nm = \case
|
||||
liftIO $ setPreparedGroupStartedConnection db groupId
|
||||
getGroupInfo db cxt user groupId
|
||||
forM_ msg_ $ \(sharedMsgId, mc) -> do
|
||||
ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing
|
||||
ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
|
||||
toView $ CEvtNewChatItems user [ci]
|
||||
pure $ CRStartedConnectionToGroup user gInfo' customUserProfile []
|
||||
CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group"
|
||||
@@ -2737,15 +2739,15 @@ 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
|
||||
let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && (isRosterRole newRole || anyPrivilegedTarget)
|
||||
rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing
|
||||
(errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems
|
||||
forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView
|
||||
unless (null acis) $ toView $ CEvtNewChatItems user acis
|
||||
let errs = errs1 <> errs2
|
||||
unless (null errs) $ toView $ CEvtChatErrors errs
|
||||
let rosterSetChanged = not (null (changed1 <> changed2)) && (isRosterRole newRole || anyPrivilegedTarget)
|
||||
when (useRelays' gInfo && memberRole' (membership gInfo) == GROwner && 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
|
||||
@@ -2780,19 +2782,20 @@ processChatCommand cxt nm = \case
|
||||
withFastStore' $ \db -> updateGroupMemberRole db user m newRole
|
||||
pure (m :: GroupMember) {memberRole = newRole}
|
||||
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
|
||||
changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
|
||||
changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of
|
||||
changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
|
||||
changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of
|
||||
Nothing -> pure ([], [], [], False)
|
||||
Just memsToChange' -> do
|
||||
let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange'
|
||||
let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing
|
||||
events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange'
|
||||
recipients = filter memberCurrent members
|
||||
(msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events
|
||||
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
|
||||
itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_)
|
||||
cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False
|
||||
when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch"
|
||||
(errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange)
|
||||
let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_
|
||||
(errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange)
|
||||
pure (errs, changed, acis, signed)
|
||||
where
|
||||
sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c
|
||||
@@ -2863,15 +2866,18 @@ 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
|
||||
let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyPrivilegedRemoved
|
||||
rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing
|
||||
(errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems
|
||||
(errs3, deleted3, acis3, signed3) <-
|
||||
foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems
|
||||
let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members
|
||||
(errs4, deleted4, acis4, signed4) <-
|
||||
foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems
|
||||
forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView
|
||||
let acis = acis2 <> acis3 <> acis4
|
||||
errs = errs1 <> errs2 <> errs3 <> errs4
|
||||
deleted = deleted1 <> deleted2 <> deleted3 <> deleted4
|
||||
@@ -2884,9 +2890,6 @@ processChatCommand cxt nm = \case
|
||||
let acis' = map (updateACIGroupInfo gInfo') acis
|
||||
unless (null acis') $ toView $ CEvtNewChatItems user acis'
|
||||
unless (null errs) $ toView $ CEvtChatErrors errs
|
||||
-- refresh the roster so the relay drops a removed privileged member for future joiners
|
||||
when (useRelays' gInfo && memberRole' (membership gInfo) == GROwner && 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, Bool)
|
||||
@@ -2915,14 +2918,14 @@ processChatCommand cxt nm = \case
|
||||
deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
|
||||
deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do
|
||||
(m', scopeInfo) <- mkMemberSupportChatInfo m
|
||||
(errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m']
|
||||
(errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m']
|
||||
pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed)
|
||||
deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
|
||||
deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of
|
||||
deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
|
||||
deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of
|
||||
Nothing -> pure ([], [], [], False)
|
||||
Just memsToDelete' -> do
|
||||
let chatScope = toChatScope <$> chatScopeInfo
|
||||
events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete'
|
||||
events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete'
|
||||
(msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events
|
||||
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
|
||||
itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_)
|
||||
@@ -3116,7 +3119,7 @@ processChatCommand cxt nm = \case
|
||||
(connId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode
|
||||
-- [incognito] reuse membership incognito profile
|
||||
ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
-- TODO not sure it is correct to set connections status here?
|
||||
pure $ CRNewMemberContact user ct g m
|
||||
_ -> throwChatError CEGroupMemberNotActive
|
||||
|
||||
@@ -79,6 +79,7 @@ import Simplex.Chat.Types.Shared
|
||||
import Simplex.Chat.Util (encryptFile, shuffle)
|
||||
import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription)
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
|
||||
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
|
||||
import Simplex.Messaging.Agent
|
||||
@@ -949,7 +950,10 @@ acceptGroupJoinRequestAsync
|
||||
-- owner-authoritative role + key); everyone else is created fresh with the group-link role
|
||||
(groupMemberId, memberId) <- case existingMem_ of
|
||||
Just m -> do
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId m initialStatus
|
||||
-- refresh the hash placeholder name from the authenticated join profile; role + key stay roster-authoritative
|
||||
withStore $ \db -> do
|
||||
liftIO $ updateGroupMemberStatus db userId m initialStatus
|
||||
void $ updateMemberProfile db user m cReqProfile
|
||||
pure (groupMemberId' m, memberId' m)
|
||||
Nothing -> withStore $ \db ->
|
||||
createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_
|
||||
@@ -1167,20 +1171,22 @@ memberIntroEvt gInfo reMember =
|
||||
mRestrictions = memberRestrictions reMember
|
||||
in XGrpMemIntro mInfo mRestrictions
|
||||
|
||||
-- Forward the saved owner-signed roster verbatim, attributed to the owner who
|
||||
-- sent it, so the recipient verifies the owner signature.
|
||||
forwardGroupRoster :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
forwardGroupRoster user gInfo subscriber = do
|
||||
-- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the
|
||||
-- blob chunks, so the recipient verifies the owner signature.
|
||||
serveRoster :: User -> GroupInfo -> GroupMember -> CM ()
|
||||
serveRoster user gInfo member = do
|
||||
cxt <- chatStoreCxt
|
||||
withStore' (\db -> getGroupRoster db gInfo) >>= \case
|
||||
Nothing -> pure ()
|
||||
Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}) ->
|
||||
forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg ->
|
||||
Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_) ->
|
||||
forM_ (eitherToMaybe (J.eitherDecodeStrict' signedBody) :: Maybe (ChatMessage 'Json)) $ \chatMsg@ChatMessage {msgId} ->
|
||||
withStore' (\db -> runExceptT $ getGroupMemberById db cxt user ownerGMId) >>= \case
|
||||
Right owner -> do
|
||||
let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs}
|
||||
sendFwdMemberMessage subscriber fwd (VMSigned MSSVerified sm chatMsg)
|
||||
sendFwdMemberMessage member fwd (VMSigned MSSVerified sm chatMsg)
|
||||
forM_ ((,) <$> msgId <*> blob_) $ \(sid, blob) ->
|
||||
sendInlineBlobChunks user gInfo [member] sid blob
|
||||
Left _ -> pure ()
|
||||
Nothing -> 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.
|
||||
@@ -1188,16 +1194,16 @@ forwardGroupRoster user gInfo subscriber = do
|
||||
introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM ()
|
||||
introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
|
||||
introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do
|
||||
(owners, rosterMems) <- withStore' $ \db ->
|
||||
(,) <$> getGroupOwners db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo
|
||||
let modMs = owners <> rosterMems
|
||||
(owners, adminsMods) <- withStore' $ \db ->
|
||||
(,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo
|
||||
let modMs = owners <> adminsMods
|
||||
void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing
|
||||
withStore' $ \db ->
|
||||
setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs]
|
||||
-- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner)
|
||||
sendIntros owners
|
||||
forwardGroupRoster user gInfo subscriber
|
||||
sendIntros rosterMems
|
||||
serveRoster user gInfo subscriber
|
||||
sendIntros adminsMods
|
||||
withStore' $ \db ->
|
||||
setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced
|
||||
where
|
||||
@@ -1235,12 +1241,13 @@ 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.
|
||||
validateGroupRoster :: GroupRoster -> GroupRoster
|
||||
validateGroupRoster GroupRoster {version = ver, roster = entries} =
|
||||
GroupRoster {version = ver, roster = dedup [] $ filter (\RosterMember {role} -> isRosterRole role) entries}
|
||||
-- Runs on the parsed roster blob.
|
||||
validateGroupRoster :: [RosterMember] -> [RosterMember]
|
||||
validateGroupRoster entries =
|
||||
dedup [] $ filter (\RosterMember {role} -> isRosterRole role) entries
|
||||
where
|
||||
dedup _ [] = []
|
||||
dedup seen (rm@RosterMember {memberId} : rms)
|
||||
@@ -1248,11 +1255,11 @@ validateGroupRoster GroupRoster {version = ver, roster = entries} =
|
||||
| otherwise = rm : dedup (memberId : seen) rms
|
||||
|
||||
-- Privileged members without a known key are skipped (recipients can't verify them).
|
||||
buildGroupRoster :: VersionRoster -> [GroupMember] -> GroupRoster
|
||||
buildGroupRoster ver mods = GroupRoster {version = ver, roster = mapMaybe rosterMember mods}
|
||||
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 ()
|
||||
@@ -1872,6 +1879,35 @@ closeFileHandle fileId files = do
|
||||
h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m)
|
||||
liftIO $ mapM_ hClose h_ `catchAll_` pure ()
|
||||
|
||||
-- The roster file has no chat item, so chat-item file enumeration misses it; clean it up by group.
|
||||
cleanupGroupRosterFile :: User -> GroupInfo -> CM ()
|
||||
cleanupGroupRosterFile User {userId} gInfo@GroupInfo {groupId} = do
|
||||
infos <- withStore' $ \db -> getGroupRosterFileInfo db userId groupId
|
||||
forM_ infos $ \(fileId, filePath_) -> do
|
||||
lift $ closeFileHandle fileId rcvFiles
|
||||
forM_ filePath_ removeFsFile
|
||||
withStore' $ \db -> do
|
||||
deleteGroupRosterFile db userId groupId
|
||||
clearRosterPending db gInfo
|
||||
|
||||
-- MUST evict the cached AppendMode handle before deleting chunks, else re-driven bytes append
|
||||
-- after the stale prefix and corrupt the blob.
|
||||
resetRosterPartialChunks :: RcvFileTransfer -> CM ()
|
||||
resetRosterPartialChunks ft@RcvFileTransfer {fileId, fileStatus} = do
|
||||
lift $ closeFileHandle fileId rcvFiles
|
||||
forM_ (rcvFilePath fileStatus) removeFsFile
|
||||
withStore' $ \db -> deleteRcvFileChunks db ft
|
||||
where
|
||||
rcvFilePath = \case
|
||||
RFSAccepted p -> Just p
|
||||
RFSConnected p -> Just p
|
||||
_ -> Nothing
|
||||
|
||||
removeFsFile :: FilePath -> CM ()
|
||||
removeFsFile fp = do
|
||||
p <- lift $ toFSFilePath fp
|
||||
removeFile p `catchAllErrors` \_ -> pure ()
|
||||
|
||||
deleteMembersConnections :: User -> [GroupMember] -> CM ()
|
||||
deleteMembersConnections user members = deleteMembersConnections' user members False
|
||||
|
||||
@@ -2172,25 +2208,49 @@ sendGroupMessage' user gInfo members chatMsgEvent =
|
||||
|
||||
-- TODO [relays] improvement: publish roster_version in link data so the owner can recover the latest version
|
||||
-- TODO after restoring from a stale backup (relays accept only strictly-greater versions)
|
||||
bumpAndBroadcastRoster :: User -> GroupInfo -> CM ()
|
||||
bumpAndBroadcastRoster user gInfo = do
|
||||
cxt <- chatStoreCxt
|
||||
-- Persist the next roster version before sending the events that carry it (so a recipient never advances
|
||||
-- past a version the owner hasn't recorded). The matching blob is broadcast separately, by broadcastRoster,
|
||||
-- after the change is applied to the owner's members - so the served roster excludes demoted/removed members.
|
||||
reserveRosterVersion :: GroupInfo -> CM VersionRoster
|
||||
reserveRosterVersion gInfo = do
|
||||
let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo)
|
||||
(relays, roster) <- withStore' $ \db -> do
|
||||
relays <- getGroupRelayMembers db cxt user gInfo
|
||||
mods <- getGroupRosterMembers db cxt user gInfo
|
||||
setGroupRosterVersion db gInfo rosterVer
|
||||
pure (relays, buildGroupRoster rosterVer mods)
|
||||
withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer
|
||||
pure rosterVer
|
||||
|
||||
broadcastRoster :: User -> GroupInfo -> VersionRoster -> CM ()
|
||||
broadcastRoster user gInfo rosterVer = do
|
||||
cxt <- chatStoreCxt
|
||||
(relays, rosterMems) <- withStore' $ \db ->
|
||||
(,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo
|
||||
forM_ (L.nonEmpty relays) $ \relays' ->
|
||||
void $ sendGroupMessage' user gInfo (L.toList relays') (XGrpRoster roster)
|
||||
sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster rosterMems)
|
||||
|
||||
-- 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 =
|
||||
forM_ (rosterVersion gInfo) $ \rosterVer -> do
|
||||
cxt <- chatStoreCxt
|
||||
mods <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo
|
||||
void $ sendGroupMessage' user gInfo [relayMember] (XGrpRoster (buildGroupRoster rosterVer mods))
|
||||
rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo
|
||||
sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems)
|
||||
|
||||
-- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's.
|
||||
sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM ()
|
||||
sendRoster user gInfo members rosterVer roster = do
|
||||
let blob = encodeRosterBlob roster
|
||||
fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob}
|
||||
SndMessage {sharedMsgId} <- sendGroupMessage' user gInfo members (XGrpRoster GroupRoster {version = rosterVer, fileInv})
|
||||
sendInlineBlobChunks user gInfo members sharedMsgId blob
|
||||
|
||||
-- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize).
|
||||
sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM ()
|
||||
sendInlineBlobChunks user gInfo members sharedMsgId blob = do
|
||||
chSize <- fromIntegral <$> asks (fileChunkSize . config)
|
||||
go chSize 1 blob
|
||||
where
|
||||
go chSize chunkNo bytes = do
|
||||
let (chunk, rest) = B.splitAt chSize bytes
|
||||
void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk))
|
||||
unless (B.null rest) $ go chSize (chunkNo + 1) rest
|
||||
|
||||
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
|
||||
@@ -2411,10 +2471,14 @@ saveDirectRcvMSG conn@Connection {connId} agentMsgMeta chatMsg@ChatMessage {chat
|
||||
msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing
|
||||
pure (conn', msg)
|
||||
|
||||
saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage)
|
||||
saveGroupRcvMsg :: forall e. MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage)
|
||||
saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta verifiedMsg = do
|
||||
let ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg
|
||||
(am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange
|
||||
-- binary messages (file chunks) carry only the initial-version sentinel, not the sender's range;
|
||||
-- applying it would downgrade the member's negotiated version and suppress version-gated delivery
|
||||
(am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- case encoding @e of
|
||||
SBinary -> pure (authorMember, conn)
|
||||
SJson -> updateMemberChatVRange authorMember conn chatVRange
|
||||
let agentMsgId = fst $ recipient agentMsgMeta
|
||||
brokerTs = metaBrokerTs agentMsgMeta
|
||||
newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs}
|
||||
@@ -2552,11 +2616,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem
|
||||
_ -> Nothing
|
||||
|
||||
-- TODO [mentions] optimize by avoiding unnecessary parsing
|
||||
mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
|
||||
mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs =
|
||||
mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d
|
||||
mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgSigned currentTs =
|
||||
let ts@(_, ft_) = ciContentTexts content
|
||||
hasLink_ = ciContentHasLink content ft_
|
||||
in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember Nothing currentTs
|
||||
in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs
|
||||
|
||||
mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d
|
||||
mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs =
|
||||
@@ -2718,7 +2782,7 @@ createFeatureEnabledItems_ :: User -> Contact -> CM [AChatItem]
|
||||
createFeatureEnabledItems_ user ct@Contact {mergedPreferences} =
|
||||
forM allChatFeatures $ \(ACF f) -> do
|
||||
let state = featureState $ getContactUserPreference f mergedPreferences
|
||||
createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing
|
||||
createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing Nothing
|
||||
|
||||
createFeatureItems ::
|
||||
MsgDirectionI d =>
|
||||
@@ -2748,15 +2812,15 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do
|
||||
unless (null errs) $ toView' $ CEvtChatErrors errs
|
||||
toView' $ CEvtNewChatItems user acis
|
||||
where
|
||||
contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])
|
||||
contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])
|
||||
contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do
|
||||
let contents = mapMaybe (\(ACF f) -> featureCIContent_ f) allChatFeatures
|
||||
(chatDir ct', False, contents)
|
||||
where
|
||||
featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId)
|
||||
featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)
|
||||
featureCIContent_ f
|
||||
| state /= state' = Just (fContent ciFeature state', Nothing)
|
||||
| prefState /= prefState' = Just (fContent ciOffer prefState', Nothing)
|
||||
| state /= state' = Just (fContent ciFeature state', Nothing, Nothing)
|
||||
| prefState /= prefState' = Just (fContent ciOffer prefState', Nothing, Nothing)
|
||||
| otherwise = Nothing
|
||||
where
|
||||
fContent :: FeatureContent a d -> (a, Maybe Int) -> CIContent d
|
||||
@@ -2789,16 +2853,16 @@ createGroupFeatureItems_ user cd showGroupAsSender ciContent GroupInfo {fullGrou
|
||||
forM allGroupFeatures $ \(AGF f) -> do
|
||||
let p = getGroupPreference f fullGroupPreferences
|
||||
(_, param, role) = groupFeatureState p
|
||||
createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing
|
||||
createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing Nothing
|
||||
|
||||
createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM ()
|
||||
createInternalChatItem user cd content itemTs_ = do
|
||||
ci <- createChatItem user cd False content Nothing itemTs_
|
||||
ci <- createChatItem user cd False content Nothing Nothing itemTs_
|
||||
toView $ CEvtNewChatItems user [ci]
|
||||
|
||||
createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe UTCTime -> CM AChatItem
|
||||
createChatItem user cd showGroupAsSender content sharedMsgId itemTs_ =
|
||||
lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId)])]) >>= \case
|
||||
createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe MsgSigStatus -> Maybe UTCTime -> CM AChatItem
|
||||
createChatItem user cd showGroupAsSender content sharedMsgId msgSigned itemTs_ =
|
||||
lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId, msgSigned)])]) >>= \case
|
||||
[Right ci] -> pure ci
|
||||
[Left e] -> throwError e
|
||||
rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs)
|
||||
@@ -2810,7 +2874,7 @@ createChatItems ::
|
||||
(ChatTypeI c, MsgDirectionI d) =>
|
||||
User ->
|
||||
Maybe UTCTime ->
|
||||
[(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])] ->
|
||||
[(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])] ->
|
||||
CM' [Either ChatError AChatItem]
|
||||
createChatItems user itemTs_ dirsCIContents = do
|
||||
createdAt <- liftIO getCurrentTime
|
||||
@@ -2819,24 +2883,24 @@ createChatItems user itemTs_ dirsCIContents = do
|
||||
void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents
|
||||
withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents
|
||||
where
|
||||
updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO ()
|
||||
updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO ()
|
||||
updateChat db cxt createdAt (cd, _, contents)
|
||||
| any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats
|
||||
| any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats
|
||||
| otherwise = pure ()
|
||||
where
|
||||
memberChatStats :: Maybe (Int, MemberAttention, Int)
|
||||
memberChatStats = case cd of
|
||||
CDGroupRcv _g (Just scope) m -> do
|
||||
let unread = length $ filter (ciRequiresAttention . fst) contents
|
||||
let unread = length $ filter (\(content, _, _) -> ciRequiresAttention content) contents
|
||||
in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0)
|
||||
_ -> Nothing
|
||||
createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem]
|
||||
createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem]
|
||||
createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents
|
||||
where
|
||||
createACI (content, sharedMsgId) = do
|
||||
createACI (content, sharedMsgId, msgSigned) = do
|
||||
let hasLink_ = ciContentHasLink content Nothing
|
||||
ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt
|
||||
let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt
|
||||
ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgSigned itemTs createdAt
|
||||
let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgSigned createdAt
|
||||
pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci
|
||||
|
||||
-- rcvMem_ Nothing means message from channel - treated same as message from moderator,
|
||||
|
||||
@@ -24,6 +24,7 @@ import Control.Monad.IO.Unlift
|
||||
import Control.Monad.Reader
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Either (lefts, partitionEithers, rights)
|
||||
import Data.Foldable (foldr', foldrM)
|
||||
import Data.Functor (($>))
|
||||
@@ -40,12 +41,14 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeLatin1)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
|
||||
import Data.Time.Format (defaultTimeLocale, formatTime)
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.UUID.V4 as V4
|
||||
import Data.Word (Word32)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Delivery
|
||||
import Simplex.Chat.Files (getChatTempDirectory)
|
||||
import Simplex.Chat.Library.Internal
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize)
|
||||
@@ -86,8 +89,10 @@ import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn)
|
||||
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||
import Simplex.Messaging.Encoding (smpEncode)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (parseAll)
|
||||
import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..))
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
|
||||
@@ -581,7 +586,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
(gInfo, host) <- withStore $ \db -> do
|
||||
liftIO $ deleteContactCardKeepConn db connId ct
|
||||
createGroupInvitedViaLink db cxt user conn'' glInv
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
-- [incognito] send saved profile
|
||||
incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId)
|
||||
let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile)
|
||||
@@ -1029,7 +1034,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pure newDeliveryTasks
|
||||
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
|
||||
processEvent gInfo' m' verifiedMsg = do
|
||||
(m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
|
||||
(m'', conn', msg@RcvMessage {msgId, sharedMsgId_, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
|
||||
let ctx js = DeliveryTaskContext js False
|
||||
checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
|
||||
checkSendAsGroup asGroup_ a
|
||||
@@ -1068,17 +1073,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_
|
||||
XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv
|
||||
XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv
|
||||
XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs
|
||||
XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole memberKey rosterVer msg brokerTs
|
||||
XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs
|
||||
XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId
|
||||
XGrpMemDel memId withMessages -> case encoding @e of
|
||||
SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages verifiedMsg msg brokerTs False
|
||||
XGrpMemDel memId withMessages rosterVer -> case encoding @e of
|
||||
SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages rosterVer verifiedMsg msg brokerTs False
|
||||
SBinary -> pure Nothing
|
||||
XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs
|
||||
XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs
|
||||
XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs
|
||||
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg
|
||||
XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' gr verifiedMsg brokerTs
|
||||
XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' gr verifiedMsg sharedMsgId_ brokerTs
|
||||
XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr
|
||||
-- TODO [knocking] why don't we forward these messages?
|
||||
XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs
|
||||
@@ -1140,7 +1145,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
checkSndInlineFTComplete conn msgId
|
||||
updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy)
|
||||
when continued $ do
|
||||
when (isUserGrpFwdRelay gInfo) $ forwardGroupRoster user gInfo m -- roster ahead of the resumed backlog
|
||||
when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog
|
||||
sendPendingGroupMessages user gInfo m conn
|
||||
SWITCH qd phase cStats -> do
|
||||
toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
|
||||
@@ -1234,7 +1239,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
QCONT -> do
|
||||
continued <- continueSending connEntity conn
|
||||
when continued $ do
|
||||
when (isUserGrpFwdRelay gInfo) $ forwardGroupRoster user gInfo m -- roster ahead of the resumed backlog
|
||||
when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog
|
||||
sendPendingGroupMessages user gInfo m conn
|
||||
MWARN msgId err -> do
|
||||
withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err)
|
||||
@@ -1308,13 +1313,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
r n'' = Just (ci, CIRcvDecryptionError mde n'')
|
||||
mdeUpdatedCI _ _ = Nothing
|
||||
|
||||
receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM ()
|
||||
receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case
|
||||
FileChunkCancel ->
|
||||
unless (rcvFileCompleteOrCancelled ft) $ do
|
||||
cancelRcvFileTransfer user ft
|
||||
ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId
|
||||
toView $ CEvtRcvFileSndCancelled user ci ft
|
||||
receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM ()
|
||||
receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case
|
||||
FileChunkCancel -> case fileType of
|
||||
FTRoster -> forM_ gInfo_ $ cleanupGroupRosterFile user
|
||||
FTNormal ->
|
||||
unless (rcvFileCompleteOrCancelled ft) $ do
|
||||
cancelRcvFileTransfer user ft
|
||||
ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId
|
||||
toView $ CEvtRcvFileSndCancelled user ci ft
|
||||
FileChunk {chunkNo, chunkBytes = chunk} -> do
|
||||
case integrity of
|
||||
MsgOk -> pure ()
|
||||
@@ -1325,21 +1332,24 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
RcvChunkOk ->
|
||||
if B.length chunk /= fromInteger chunkSize
|
||||
then badRcvFileChunk ft "incorrect chunk size"
|
||||
else withAckMessage' "file msg" agentConnId meta $ appendFileChunk ft chunkNo chunk False
|
||||
else appendFileChunk ft chunkNo chunk False
|
||||
RcvChunkFinal ->
|
||||
if B.length chunk > fromInteger chunkSize
|
||||
then badRcvFileChunk ft "incorrect chunk size"
|
||||
else do
|
||||
appendFileChunk ft chunkNo chunk True
|
||||
ci <- withStore $ \db -> do
|
||||
liftIO $ do
|
||||
updateRcvFileStatus db fileId FSComplete
|
||||
updateCIFileStatus db user fileId CIFSRcvComplete
|
||||
deleteRcvFileChunks db ft
|
||||
getChatItemByFileId db cxt user fileId
|
||||
toView $ CEvtRcvFileComplete user ci
|
||||
mapM_ (deleteAgentConnectionAsync . aConnId) conn_
|
||||
RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure ()
|
||||
case fileType of
|
||||
FTRoster -> forM_ gInfo_ $ \gInfo -> rosterCompletion gInfo ft
|
||||
FTNormal -> do
|
||||
ci <- withStore $ \db -> do
|
||||
liftIO $ do
|
||||
updateRcvFileStatus db fileId FSComplete
|
||||
updateCIFileStatus db user fileId CIFSRcvComplete
|
||||
deleteRcvFileChunks db ft
|
||||
getChatItemByFileId db cxt user fileId
|
||||
toView $ CEvtRcvFileComplete user ci
|
||||
mapM_ (deleteAgentConnectionAsync . aConnId) conn_
|
||||
RcvChunkDuplicate -> pure ()
|
||||
RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo
|
||||
|
||||
processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM ()
|
||||
@@ -1383,9 +1393,6 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
events = XGrpRelayNew <$> newlyActive
|
||||
unless (null recipients) $
|
||||
void $ sendGroupMessages user gInfo Nothing False recipients events
|
||||
-- send the current roster to relays that just became active so they can serve joiners
|
||||
forM_ newlyActiveGMIds $ \gmId ->
|
||||
(withStore (\db -> getGroupMemberById db cxt user gmId) >>= sendGroupRosterToRelay user gInfo) `catchAllErrors` eToView
|
||||
where
|
||||
updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId])
|
||||
updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) =
|
||||
@@ -1445,12 +1452,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- they will be updated after connection is accepted.
|
||||
upsertDirectRequestItem cd (requestMsg_, prevSharedMsgId_)
|
||||
Nothing -> do
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let e2eContent = CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just $ CR.pqSupportToEnc $ reqPQSup
|
||||
void $ createChatItem user cd False e2eContent Nothing Nothing
|
||||
void $ createChatItem user cd False e2eContent Nothing Nothing Nothing
|
||||
void $ createFeatureEnabledItems_ user ct
|
||||
forM_ (autoReply addressSettings) $ \mc -> forM_ welcomeSharedMsgId $ \sharedMsgId ->
|
||||
createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing
|
||||
createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
|
||||
mapM (createRequestItem cd) requestMsg_
|
||||
case autoAccept of
|
||||
Nothing -> do
|
||||
@@ -1475,13 +1482,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- they will be updated after connection is accepted.
|
||||
upsertBusinessRequestItem cd (requestMsg_, prevSharedMsgId_)
|
||||
Nothing -> do
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
-- TODO [short links] possibly, we can just keep them created where they are created on the business side due to auto-accept
|
||||
-- let e2eContent = CIRcvGroupE2EEInfo $ E2EInfo $ Just False -- no PQ encryption in groups
|
||||
-- void $ createChatItem user cd False e2eContent Nothing Nothing
|
||||
-- void $ createChatItem user cd False e2eContent Nothing Nothing Nothing
|
||||
-- void $ createFeatureEnabledItems_ user ct
|
||||
forM_ (autoReply addressSettings) $ \arMC -> forM_ welcomeSharedMsgId $ \sharedMsgId ->
|
||||
createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing
|
||||
createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing Nothing
|
||||
mapM (createRequestItem cd) requestMsg_
|
||||
toView $ CEvtAcceptingBusinessRequest user gInfo
|
||||
where
|
||||
@@ -1545,7 +1552,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing
|
||||
createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem
|
||||
createRequestItem cd (sharedMsgId, mc) = do
|
||||
aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing
|
||||
aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing Nothing
|
||||
toView $ CEvtNewChatItems user [aci]
|
||||
pure aci
|
||||
upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem)
|
||||
@@ -2163,7 +2170,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
unless (maybe False memberBlocked m') $ autoAcceptFile file_
|
||||
processFileInv gInfo' m' =
|
||||
let fileMember_ = if sentAsGroup then Nothing else m'
|
||||
in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_
|
||||
in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_
|
||||
newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do
|
||||
let mentions' = if maybe False memberBlocked m' then M.empty else mentions
|
||||
(ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions'
|
||||
@@ -2370,7 +2377,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do
|
||||
ChatConfig {fileChunkSize} <- asks config
|
||||
inline <- receiveInlineMode fInv Nothing fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv inline fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv inline fileChunkSize
|
||||
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
|
||||
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol}
|
||||
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
|
||||
@@ -2466,10 +2473,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user
|
||||
receiveInlineChunk ft chunk meta
|
||||
|
||||
-- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by
|
||||
-- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster
|
||||
-- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it.
|
||||
bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM ()
|
||||
bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do
|
||||
ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user
|
||||
receiveInlineChunk ft chunk meta
|
||||
bFileChunkGroup gInfo@GroupInfo {groupId} sharedMsgId chunk meta = do
|
||||
fileId_ <- withStore' $ \db -> getGroupRcvFileIdBySharedMsgId db userId groupId sharedMsgId
|
||||
forM_ fileId_ $ \fileId -> do
|
||||
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
|
||||
case fileType ft of
|
||||
FTRoster -> receiveRosterChunk gInfo ft meta chunk
|
||||
FTNormal -> receiveInlineChunk ft chunk meta
|
||||
|
||||
receiveInlineChunk :: RcvFileTransfer -> FileChunk -> MsgMeta -> CM ()
|
||||
receiveInlineChunk RcvFileTransfer {fileId, fileStatus = RFSNew} FileChunk {chunkNo} _
|
||||
@@ -2479,7 +2493,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
case chunk of
|
||||
FileChunk {chunkNo} -> when (chunkNo == 1) $ startReceivingFile user fileId
|
||||
_ -> pure ()
|
||||
receiveFileChunk ft Nothing meta chunk
|
||||
receiveFileChunk Nothing ft Nothing meta chunk
|
||||
|
||||
-- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a
|
||||
-- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob.
|
||||
receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM ()
|
||||
receiveRosterChunk gInfo ft meta chunk = do
|
||||
case chunk of
|
||||
FileChunk {chunkNo} | chunkNo == 1 -> do
|
||||
last_ <- withStore' $ \db -> getRcvFileLastChunkNo db ft
|
||||
when (isJust last_) $ resetRosterPartialChunks ft
|
||||
_ -> pure ()
|
||||
receiveFileChunk (Just gInfo) ft Nothing meta chunk
|
||||
|
||||
xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)
|
||||
xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do
|
||||
@@ -2537,7 +2562,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
|
||||
-- [incognito] if direct connection with host is incognito, create membership using the same incognito profile
|
||||
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let GroupMember {groupMemberId, memberId = membershipMemId} = membership
|
||||
if sameGroupLinkId groupLinkId groupLinkId'
|
||||
then do
|
||||
@@ -3092,10 +3117,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
messageError "x.grp.mem.intro ignored: member already exists"
|
||||
Left _
|
||||
| useRelays' gInfo -> do
|
||||
-- drop the relay-asserted key for privileged roles; their keys come from the roster, not intros
|
||||
-- role + key are owner-authoritative (roster); an intro establishes neither - a privileged
|
||||
-- claim is created at the channel default with no key until the owner-signed roster confirms it
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
let memInfo' = case memInfo of
|
||||
MemberInfo mId mRole v p _
|
||||
| mRole > GRMember -> MemberInfo mId mRole v p Nothing
|
||||
| mRole >= GRMember -> MemberInfo mId defaultRole v p Nothing
|
||||
_ -> memInfo
|
||||
void $ withStore $ \db -> createIntroReMember db user gInfo memInfo' memRestrictions
|
||||
| otherwise -> do
|
||||
@@ -3165,116 +3192,204 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
chatV = vr cxt `peerConnChatVersion` mcvr
|
||||
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
|
||||
|
||||
xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs
|
||||
-- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted
|
||||
-- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one
|
||||
-- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored.
|
||||
applyAtRosterVersion :: GroupInfo -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope)
|
||||
applyAtRosterVersion gInfo rosterVer_ action
|
||||
| not (useRelays' gInfo) = action
|
||||
| otherwise = case rosterVer_ of
|
||||
Nothing -> action
|
||||
Just v -> do
|
||||
accept <- withStore' $ \db -> do
|
||||
cur <- getGroupRosterVersion db gInfo
|
||||
let fresh = maybe True (v >=) cur
|
||||
when fresh $ setGroupRosterVersion db gInfo v
|
||||
pure fresh
|
||||
if accept
|
||||
then action
|
||||
else messageWarning "x.grp.mem: roster version not newer than current, ignoring" $> Nothing
|
||||
|
||||
xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs
|
||||
| membershipMemId == memId =
|
||||
let gInfo' = gInfo {membership = membership {memberRole = memRole}}
|
||||
in changeMemberRole gInfo' membership $ RGEUserRole memRole
|
||||
| otherwise =
|
||||
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case
|
||||
Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
|
||||
-- in relay groups the roster delivers the chat item for previously-unknown privileged members
|
||||
Left _
|
||||
| useRelays' gInfo -> pure Nothing
|
||||
applyAtRosterVersion gInfo rosterVer_ $
|
||||
let gInfo' = gInfo {membership = membership {memberRole = memRole}}
|
||||
in changeMemberRole gInfo' membership (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole
|
||||
| otherwise = applyAtRosterVersion gInfo 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
|
||||
let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_
|
||||
withStore' (\db -> runExceptT $ getCreateUnknownGMByMemberId db cxt user gInfo memId (nameFromMemberId memId) defaultRole allowCreate) >>= \case
|
||||
Right (Just (member, created))
|
||||
-- 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 (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent
|
||||
-- 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 (\db -> updateGroupMemberRole db user member memRole) gEvent
|
||||
-- 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
|
||||
where
|
||||
GroupMember {memberId = membershipMemId} = membership
|
||||
changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent
|
||||
-- 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} applyMember gEvent
|
||||
| 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
|
||||
-- in channels a forwarded role event that the roster already applied is a no-op; suppress it
|
||||
| useRelays' gInfo && fromRole == memRole = pure $ memberEventDeliveryScope member
|
||||
| otherwise = do
|
||||
withStore' $ \db -> updateGroupMemberRole db user member memRole
|
||||
withStore' applyMember
|
||||
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
|
||||
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent)
|
||||
groupMsgToView cInfo ci
|
||||
toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned}
|
||||
pure $ memberEventDeliveryScope member
|
||||
|
||||
xGrpRoster :: MsgEncodingI e => GroupInfo -> GroupMember -> GroupRoster -> VerifiedMsg e -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpRoster gInfo author roster verifiedMsg brokerTs
|
||||
-- The header only starts the transfer; the roster is applied and the version bumped only at
|
||||
-- blob completion, so a withheld or corrupted blob leaves the last good roster intact.
|
||||
xGrpRoster :: GroupInfo -> GroupMember -> GroupRoster -> VerifiedMsg e -> Maybe SharedMsgId -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
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
|
||||
| length entries > maxGroupRosterSize = messageError ("x.grp.roster: too many entries, max " <> tshow maxGroupRosterSize) $> Nothing
|
||||
| isUserGrpFwdRelay gInfo = relayApplyRoster
|
||||
| otherwise = Nothing <$ memberApplyRoster
|
||||
| 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
|
||||
VMSigned _ sm _ -> case sharedMsgId_ of
|
||||
Nothing -> Nothing <$ messageWarning "x.grp.roster: missing shared message id"
|
||||
Just sharedMsgId -> do
|
||||
pendingVer_ <- fmap (\(v, _, _, _, _) -> v) <$> withStore' (\db -> getRosterPending db gInfo)
|
||||
-- accept a version not below BOTH applied and pending (>=, Nothing below 0): a preceding signed
|
||||
-- event may have already advanced rosterVersion to this blob's version; a lower one is a downgrade.
|
||||
if newVer `aboveRoster` rosterVersion gInfo && newVer `aboveRoster` pendingVer_
|
||||
then startRosterTransfer sm sharedMsgId
|
||||
else pure Nothing
|
||||
where
|
||||
startRosterTransfer sm sharedMsgId = do
|
||||
-- supersede any in-flight roster file (older version or a restart) before the new transfer
|
||||
cleanupGroupRosterFile user gInfo
|
||||
let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing
|
||||
withStore' $ \db -> setRosterPending db gInfo newVer fileDigest (groupMemberId' author) brokerTs relayHdr
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing}
|
||||
rft@RcvFileTransfer {fileId} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo Nothing FTRoster (Just sharedMsgId) rosterFInv (Just IFMSent) (fromIntegral chSize)
|
||||
-- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew
|
||||
-- transient scratch file (consumed into roster_blob, then deleted): temp folder, not the user's files folder / Downloads
|
||||
tmpDir <- lift getChatTempDirectory
|
||||
rosterTs <- liftIO getCurrentTime
|
||||
let GroupInfo {groupId = gId} = gInfo
|
||||
rosterFile = "roster_" <> show gId <> "_" <> formatTime defaultTimeLocale "%Y%m%d_%H%M%S" rosterTs
|
||||
filePath <- getRcvFilePath fileId (Just tmpDir) rosterFile False
|
||||
withStore' $ \db -> startRcvInlineFT db user rft filePath (Just IFMSent)
|
||||
pure Nothing
|
||||
|
||||
-- Roster version comparison treating Nothing (un-materialized) as below 0. Non-strict (>=) so a relay
|
||||
-- accepts the owner's blob at the version a preceding signed event already advanced rosterVersion to.
|
||||
aboveRoster :: VersionRoster -> Maybe VersionRoster -> Bool
|
||||
aboveRoster v = maybe True (v >=)
|
||||
|
||||
-- Blob arrived: verify the owner-attested digest over the plaintext and guard against
|
||||
-- downgrade before applying; on a relay, ack the owner and re-serve to members.
|
||||
rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM ()
|
||||
rosterCompletion gInfo RcvFileTransfer {fileStatus} =
|
||||
withStore' (\db -> getRosterPending db gInfo) >>= \case
|
||||
Nothing -> cleanupGroupRosterFile user gInfo
|
||||
Just (pendingVer, pendingDigest, ownerGMId, rosterBrokerTs, _) -> do
|
||||
owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db cxt user ownerGMId)
|
||||
blob <- readAssembledRoster
|
||||
let isRelay = isUserGrpFwdRelay gInfo
|
||||
ackErr err = do
|
||||
cleanupGroupRosterFile user gInfo
|
||||
when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err)
|
||||
if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest
|
||||
then ackErr "relay could not verify the roster blob"
|
||||
else case parseAll rosterBlobP blob of
|
||||
Left _ -> ackErr "relay could not parse the roster blob"
|
||||
Right entries -> case owner_ of
|
||||
Nothing -> cleanupGroupRosterFile user gInfo
|
||||
Just author -> do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
-- gate against the persisted roster_version inside the apply transaction, so a reordered
|
||||
-- same-batch event cannot make this completion downgrade it; stale completion is rejected.
|
||||
results_ <- withStore $ \db -> do
|
||||
cur <- liftIO $ getGroupRosterVersion db gInfo
|
||||
if maybe False (pendingVer <) cur
|
||||
then pure Nothing
|
||||
else do
|
||||
res <- processRosterEntries db gInfo defaultRole (validateGroupRoster entries)
|
||||
liftIO $ promoteRosterPending db gInfo blob
|
||||
pure (Just res)
|
||||
cleanupGroupRosterFile user gInfo
|
||||
forM_ results_ $ \results -> do
|
||||
emitRosterResults gInfo author rosterBrokerTs results
|
||||
-- ack only while still setting up (own status RSAccepted); a serving relay must not ack broadcasts.
|
||||
when (isRelay && relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck gInfo author pendingVer Nothing
|
||||
where
|
||||
readAssembledRoster = case fileStatus of
|
||||
RFSAccepted fp -> readAt fp
|
||||
RFSConnected fp -> readAt fp
|
||||
RFSComplete fp -> readAt fp
|
||||
_ -> throwChatError $ CEInternalError "roster file not in progress"
|
||||
readAt fp = lift (toFSFilePath fp) >>= liftIO . B.readFile
|
||||
|
||||
-- TOFU-apply an owner-signed (key, role) to a resolved member: pin the key if absent; for a keyed
|
||||
-- member keep the trusted key (Left = reject a different one), else update the role. Right
|
||||
-- (Just (member-at-new-role, fromRole)) when the role changed, Right Nothing when already current.
|
||||
applyMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO (Either MemberId (Maybe (GroupMember, GroupMemberRole)))
|
||||
applyMemberKeyRole db m pubKey role = case memberPubKey m of
|
||||
Just k
|
||||
| k /= pubKey -> pure (Left (memberId' m))
|
||||
| memberRole' m == role -> pure (Right Nothing)
|
||||
| otherwise -> updateGroupMemberRole db user m role $> Right (Just (m {memberRole = role}, memberRole' m))
|
||||
Nothing -> setGroupMemberKeyRole db m pubKey role $> Right (Just (m {memberRole = role}, memberRole' 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, Bool)])
|
||||
processRosterEntries db gInfo defaultRole entries = do
|
||||
let rosterIds = map (\RosterMember {memberId} -> memberId) entries
|
||||
(cs, as) <- foldrM applyRosterEntry ([], []) entries
|
||||
currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo
|
||||
reverted <- liftIO $ fmap catMaybes $ forM currentPriv $ \m ->
|
||||
if memberId' m `notElem` rosterIds
|
||||
then updateGroupMemberRole db user m defaultRole $> Just ((m :: GroupMember) {memberRole = defaultRole}, memberRole' m, False)
|
||||
else pure Nothing
|
||||
pure (cs, as <> reverted)
|
||||
where
|
||||
-- entry-level failure (StoreError or IO exception) is muted; the entry is dropped
|
||||
applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) =
|
||||
( getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case
|
||||
Nothing -> pure (cs, as)
|
||||
Just (m, created) -> liftIO (applyMemberKeyRole db m pubKey role) >>= \case
|
||||
Left mid -> pure (mid : cs, as)
|
||||
Right Nothing -> pure (cs, as)
|
||||
Right (Just (rm, fromR)) -> pure (cs, (rm, fromR, created) : as)
|
||||
)
|
||||
`catchAllErrors` \_ -> pure (cs, as)
|
||||
|
||||
emitRosterResults :: GroupInfo -> GroupMember -> UTCTime -> ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) -> CM ()
|
||||
emitRosterResults gInfo author rosterBrokerTs (conflicts, applied) = do
|
||||
forM_ conflicts $ \mid' ->
|
||||
messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid')
|
||||
forM_ applied $ \(member, fromRole, created) ->
|
||||
unless created $ createItems member fromRole
|
||||
where
|
||||
GroupRoster {version = newVer, roster = entries} = validRoster
|
||||
validRoster = validateGroupRoster roster
|
||||
relayApplyRoster :: CM (Maybe DeliveryJobScope)
|
||||
relayApplyRoster
|
||||
| maybe False (newVer <=) (rosterVersion gInfo) = Nothing <$ messageWarning "x.grp.roster: not newer than saved version"
|
||||
| otherwise = case verifiedMsg of
|
||||
VMSigned _ sm _ ->
|
||||
tryAllErrors (setRoster sm) >>= \case
|
||||
Right results -> do
|
||||
emitRosterResults results
|
||||
-- ack only while still setting up (own status RSAccepted); a serving relay (RSActive) must not ack roster broadcasts
|
||||
when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer Nothing
|
||||
-- always broadcast on a bump: self-healing, and demotions must reach members
|
||||
pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}
|
||||
Left e -> do
|
||||
eToView e
|
||||
when (relayOwnStatus gInfo == Just RSAccepted) $ sendRosterAck author newVer (Just "relay could not save the roster")
|
||||
pure Nothing
|
||||
VMUnsigned _ -> Nothing <$ messageWarning "x.grp.roster: unsigned roster"
|
||||
setRoster :: SignedMsg -> CM ([MemberId], [(GroupMember, GroupMemberRole)])
|
||||
setRoster sm = do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
withStore $ \db -> do
|
||||
res <- processRoster db defaultRole
|
||||
liftIO $ setGroupRoster db gInfo newVer (groupMemberId' author) brokerTs sm
|
||||
pure res
|
||||
memberApplyRoster :: CM ()
|
||||
memberApplyRoster
|
||||
| maybe False (newVer <) (rosterVersion gInfo) = messageWarning "x.grp.roster: older than accepted version"
|
||||
| maybe False (newVer ==) (rosterVersion gInfo) = pure ()
|
||||
| otherwise = do
|
||||
defaultRole <- unknownMemberRole gInfo
|
||||
results <- withStore $ \db -> do
|
||||
res <- processRoster db defaultRole
|
||||
liftIO $ setGroupRosterVersion db gInfo newVer
|
||||
pure res
|
||||
emitRosterResults results
|
||||
processRoster :: DB.Connection -> GroupMemberRole -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole)])
|
||||
processRoster db defaultRole = do
|
||||
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
|
||||
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) =
|
||||
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
|
||||
Nothing -> pure (cs, as)
|
||||
Just (m, _) -> case memberPubKey m of
|
||||
Just k
|
||||
| k /= pubKey -> pure (memberId : cs, as)
|
||||
| memberRole' m == role -> pure (cs, as)
|
||||
| otherwise -> liftIO (updateGroupMemberRole db user m role) $> applied m
|
||||
Nothing -> liftIO (setGroupMemberKeyRole db m pubKey role) $> applied m
|
||||
emitRosterResults :: ([MemberId], [(GroupMember, GroupMemberRole)]) -> CM ()
|
||||
emitRosterResults (conflicts, applied) = do
|
||||
forM_ conflicts $ \mid' ->
|
||||
messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid')
|
||||
forM_ applied $ \(member, fromRole) -> createItems member fromRole
|
||||
createItems :: GroupMember -> GroupMemberRole -> CM ()
|
||||
createItems member fromRole = do
|
||||
let toRole = memberRole' member
|
||||
gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) toRole
|
||||
(gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author
|
||||
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo author') (CIRcvGroupEvent gEvent) (Just brokerTs)
|
||||
ci <- createChatItem user (CDGroupRcv gInfo' scopeInfo author') False (CIRcvGroupEvent gEvent) Nothing (Just MSSVerified) (Just rosterBrokerTs)
|
||||
toView $ CEvtNewChatItems user [ci]
|
||||
toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified}
|
||||
sendRosterAck :: GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
sendRosterAck owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err)
|
||||
|
||||
sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err)
|
||||
|
||||
xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
|
||||
xGrpRosterAck gInfo m ackVer err = do
|
||||
@@ -3340,8 +3455,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected
|
||||
withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected
|
||||
|
||||
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
|
||||
xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
|
||||
let GroupMember {memberId = membershipMemId} = membership
|
||||
if membershipMemId == memId
|
||||
then checkRole membership $ do
|
||||
@@ -3356,7 +3471,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
deleteMemberItem msg gInfo RGEUserDeleted
|
||||
toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned
|
||||
pure $ Just DJSGroup {jobSpec = DJRelayRemoved}
|
||||
else
|
||||
else applyAtRosterVersion gInfo rosterVer_ $
|
||||
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case
|
||||
Left _ -> do
|
||||
messageError "x.grp.mem.del with unknown member ID"
|
||||
@@ -3435,9 +3550,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 ()
|
||||
@@ -3608,7 +3720,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM ()
|
||||
processForwardedMsg verifiedMsg author_ = do
|
||||
rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs
|
||||
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of
|
||||
forM_ rcvMsg_ $ \rcvMsg@RcvMessage {sharedMsgId_, chatMsgEvent = ACME _ event} -> case event of
|
||||
XMsgNew mc ->
|
||||
void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True
|
||||
where
|
||||
@@ -3623,14 +3735,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs
|
||||
XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl
|
||||
XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs
|
||||
XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs
|
||||
XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole memberKey rosterVer rcvMsg msgTs
|
||||
XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs
|
||||
XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True
|
||||
XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True
|
||||
XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs
|
||||
XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs
|
||||
XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs
|
||||
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg
|
||||
XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo author gr verifiedMsg msgTs
|
||||
XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo author gr verifiedMsg sharedMsgId_ msgTs
|
||||
_ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event)
|
||||
where
|
||||
withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM ()
|
||||
|
||||
@@ -48,12 +48,13 @@ import Data.Time.Clock (UTCTime)
|
||||
import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
|
||||
import Data.Type.Equality
|
||||
import Data.Typeable (Typeable)
|
||||
import Data.Word (Word32)
|
||||
import Data.Word (Word16, Word32)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
|
||||
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
|
||||
import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize)
|
||||
@@ -367,22 +368,37 @@ data GrpMsgForward = GrpMsgForward
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Owner-signed snapshot of the privileged (moderator/admin) set; owners are
|
||||
-- not included, their keys come from the link.
|
||||
-- | Owner-signed roster header for the privileged (moderator/admin/member) set; owners
|
||||
-- are not included, their keys come from the link. The member list itself is not
|
||||
-- here: it is sent as a binary blob over the inline file transfer, and this header
|
||||
-- carries only its inline-file invitation (size + owner-attested digest).
|
||||
data GroupRoster = GroupRoster
|
||||
{ version :: VersionRoster,
|
||||
roster :: [RosterMember]
|
||||
fileInv :: InlineFileInvitation
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Lean always-inline file invitation for the roster blob, carried in the signed
|
||||
-- header. The digest authenticates the unsigned blob; integrity is entirely the digest.
|
||||
data InlineFileInvitation = InlineFileInvitation
|
||||
{ fileSize :: Integer,
|
||||
fileDigest :: FD.FileDigest
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
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)
|
||||
|
||||
-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message.
|
||||
instance Encoding RosterMember where
|
||||
smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges)
|
||||
smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
|
||||
|
||||
instance Encoding FwdSender where
|
||||
smpEncode = \case
|
||||
FwdMember memberId memberName -> smpEncode ('M', memberId, memberName)
|
||||
@@ -485,11 +501,11 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
|
||||
XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json
|
||||
XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json
|
||||
XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json
|
||||
XGrpMemRole :: MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> ChatMsgEvent 'Json
|
||||
XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json
|
||||
XGrpMemCon :: MemberId -> ChatMsgEvent 'Json
|
||||
XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
|
||||
XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json
|
||||
XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json
|
||||
XGrpLeave :: ChatMsgEvent 'Json
|
||||
XGrpDel :: ChatMsgEvent 'Json
|
||||
XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json
|
||||
@@ -809,7 +825,7 @@ data MsgMention = MsgMention {memberId :: MemberId}
|
||||
newtype MsgMentions = MsgMentions (Map MemberName MsgMention)
|
||||
deriving (Eq, Show)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''RosterMember)
|
||||
$(JQ.deriveJSON defaultJSON ''InlineFileInvitation)
|
||||
|
||||
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink)
|
||||
|
||||
@@ -911,11 +927,27 @@ maxCompressedMsgLength = 13380
|
||||
maxDecompressedMsgLength :: Int
|
||||
maxDecompressedMsgLength = 65536
|
||||
|
||||
-- Bound so the signed roster fits maxEncodedMsgLength (15602 B): ~140 B per
|
||||
-- RosterMember (memberId + Ed25519 key both base64, role, name capped at 16
|
||||
-- chars by memberShortenedName). Fits 64 entries even with 4-byte-UTF-8 names.
|
||||
-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
|
||||
-- promotion cap over the promoted (member/moderator/admin) set.
|
||||
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.
|
||||
-- 128 B/entry ~ memberId + X.509 Ed25519 key (44 B) + role + privileges + 1-byte length prefixes (~2x the ~65 B typical).
|
||||
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).
|
||||
encodeRosterBlob :: [RosterMember] -> ByteString
|
||||
encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms)
|
||||
|
||||
rosterBlobP :: A.Parser [RosterMember]
|
||||
rosterBlobP = do
|
||||
n <- fromIntegral <$> smpP @Word16
|
||||
when (n > maxGroupRosterSize) $ fail "roster: too many entries"
|
||||
A.count n smpP
|
||||
|
||||
-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead)
|
||||
-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008
|
||||
@@ -1227,7 +1259,7 @@ toCMEventTag msg = case msg of
|
||||
XGrpMemInv _ _ -> XGrpMemInv_
|
||||
XGrpMemFwd _ _ -> XGrpMemFwd_
|
||||
XGrpMemInfo _ _ -> XGrpMemInfo_
|
||||
XGrpMemRole _ _ -> XGrpMemRole_
|
||||
XGrpMemRole {} -> XGrpMemRole_
|
||||
XGrpMemRestrict _ _ -> XGrpMemRestrict_
|
||||
XGrpMemCon _ -> XGrpMemCon_
|
||||
XGrpMemConAll _ -> XGrpMemConAll_
|
||||
@@ -1388,17 +1420,17 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
|
||||
XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro"
|
||||
XGrpMemInfo_ -> XGrpMemInfo <$> p "memberId" <*> p "profile"
|
||||
XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role"
|
||||
XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" <*> opt "memberKey" <*> opt "rosterVersion"
|
||||
XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions"
|
||||
XGrpMemCon_ -> XGrpMemCon <$> p "memberId"
|
||||
XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId"
|
||||
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages")
|
||||
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion"
|
||||
XGrpLeave_ -> pure XGrpLeave
|
||||
XGrpDel_ -> pure XGrpDel
|
||||
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
|
||||
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
|
||||
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
|
||||
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "roster")
|
||||
XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv")
|
||||
XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error"
|
||||
XGrpMsgForward_ -> do
|
||||
fwdSender <- opt "memberId" >>= \case
|
||||
@@ -1462,17 +1494,17 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
|
||||
XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro]
|
||||
XGrpMemInfo memId profile -> o ["memberId" .= memId, "profile" .= profile]
|
||||
XGrpMemRole memId role -> o ["memberId" .= memId, "role" .= role]
|
||||
XGrpMemRole memId role memberKey rosterVersion -> o $ ("memberKey" .=? memberKey) $ ("rosterVersion" .=? rosterVersion) ["memberId" .= memId, "role" .= role]
|
||||
XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions]
|
||||
XGrpMemCon memId -> o ["memberId" .= memId]
|
||||
XGrpMemConAll memId -> o ["memberId" .= memId]
|
||||
XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
|
||||
XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
|
||||
XGrpLeave -> JM.empty
|
||||
XGrpDel -> JM.empty
|
||||
XGrpInfo p -> o ["groupProfile" .= p]
|
||||
XGrpPrefs p -> o ["groupPreferences" .= p]
|
||||
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
|
||||
XGrpRoster GroupRoster {version, roster} -> o ["version" .= version, "roster" .= roster]
|
||||
XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv]
|
||||
XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version]
|
||||
XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs]
|
||||
where
|
||||
|
||||
@@ -31,6 +31,10 @@ module Simplex.Chat.Store.Files
|
||||
getSharedMsgIdByFileId,
|
||||
getFileIdBySharedMsgId,
|
||||
getGroupFileIdBySharedMsgId,
|
||||
getGroupRcvFileIdBySharedMsgId,
|
||||
getGroupRosterFileInfo,
|
||||
deleteGroupRosterFile,
|
||||
getRcvFileLastChunkNo,
|
||||
getDirectFileIdBySharedMsgId,
|
||||
getChatRefByFileId,
|
||||
lookupChatRefByFileId,
|
||||
@@ -320,6 +324,42 @@ getGroupFileIdBySharedMsgId db userId groupId sharedMsgId =
|
||||
|]
|
||||
(userId, groupId, sharedMsgId)
|
||||
|
||||
-- Locates a received group file (normal or roster) by the header's shared_msg_id without the
|
||||
-- chat_items join, so it finds the chat-item-free roster blob and normal inline files alike.
|
||||
-- Nothing => no in-flight transfer (an orphaned re-served chunk), which the caller ACKs and ignores.
|
||||
getGroupRcvFileIdBySharedMsgId :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Maybe Int64)
|
||||
getGroupRcvFileIdBySharedMsgId db userId groupId sharedMsgId =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT f.file_id FROM files f
|
||||
JOIN rcv_files r ON r.file_id = f.file_id
|
||||
WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ?
|
||||
|]
|
||||
(userId, groupId, sharedMsgId)
|
||||
|
||||
-- For roster-file cleanup keyed on the group (not a chat item): every matching file_id and its on-disk
|
||||
-- path, so the caller evicts the handle and removes the file for each — delete-all like deleteGroupRosterFile.
|
||||
getGroupRosterFileInfo :: DB.Connection -> UserId -> Int64 -> IO [(Int64, Maybe FilePath)]
|
||||
getGroupRosterFileInfo db userId groupId =
|
||||
DB.query
|
||||
db
|
||||
"SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?"
|
||||
(userId, groupId, FTRoster)
|
||||
|
||||
-- Deletes the roster files row; rcv_files and rcv_file_chunks cascade on the FK.
|
||||
deleteGroupRosterFile :: DB.Connection -> UserId -> Int64 -> IO ()
|
||||
deleteGroupRosterFile db userId groupId =
|
||||
DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" (userId, groupId, FTRoster)
|
||||
|
||||
-- The highest stored chunk number, or Nothing if no partial chunks exist (used to decide
|
||||
-- whether an arriving chunk 1 is a re-driven transfer that must reset).
|
||||
getRcvFileLastChunkNo :: DB.Connection -> RcvFileTransfer -> IO (Maybe Integer)
|
||||
getRcvFileLastChunkNo db RcvFileTransfer {fileId} =
|
||||
maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId)
|
||||
|
||||
getDirectFileIdBySharedMsgId :: DB.Connection -> User -> Contact -> SharedMsgId -> ExceptT StoreError IO Int64
|
||||
getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
|
||||
ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $
|
||||
@@ -378,10 +418,10 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
|
||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs)
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
|
||||
createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
|
||||
createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr
|
||||
let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_
|
||||
@@ -393,15 +433,15 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam
|
||||
fileId <- liftIO $ do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
|
||||
(userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs)
|
||||
"INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
(userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs)
|
||||
insertedRowId db
|
||||
liftIO $
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
|
||||
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs)
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
|
||||
|
||||
createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64
|
||||
createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do
|
||||
@@ -530,7 +570,7 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name,
|
||||
f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name,
|
||||
f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline,
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type
|
||||
FROM rcv_files r
|
||||
JOIN files f USING (file_id)
|
||||
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
|
||||
@@ -544,9 +584,9 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
where
|
||||
rcvFileTransfer ::
|
||||
Maybe RcvFileDescr ->
|
||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) ->
|
||||
(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) ->
|
||||
ExceptT StoreError IO RcvFileTransfer
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) =
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) =
|
||||
case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just name ->
|
||||
@@ -564,7 +604,7 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
|
||||
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
filePath = case filePath_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just fp -> pure fp
|
||||
@@ -660,7 +700,15 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation {
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)"
|
||||
[sql|
|
||||
INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT (file_id, chunk_number) DO UPDATE SET
|
||||
chunk_agent_msg_id = excluded.chunk_agent_msg_id,
|
||||
chunk_stored = 0,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at
|
||||
|]
|
||||
(fileId, chunkNo, msgId, currentTs, currentTs)
|
||||
pure status
|
||||
where
|
||||
|
||||
@@ -68,6 +68,8 @@ module Simplex.Chat.Store.Groups
|
||||
getSupportScopeMembersByIndexes,
|
||||
getGroupModerators,
|
||||
getGroupRosterMembers,
|
||||
getGroupAdminsMods,
|
||||
getGroupOnlyMembers,
|
||||
getGroupOwners,
|
||||
getGroupRelayMembers,
|
||||
getGroupMembersForExpiration,
|
||||
@@ -87,8 +89,12 @@ module Simplex.Chat.Store.Groups
|
||||
getGroupRelays,
|
||||
getConnectedGroupRelays,
|
||||
setGroupRosterVersion,
|
||||
setGroupRoster,
|
||||
getGroupRosterVersion,
|
||||
getGroupRoster,
|
||||
setRosterPending,
|
||||
getRosterPending,
|
||||
promoteRosterPending,
|
||||
clearRosterPending,
|
||||
setGroupMemberKeyRole,
|
||||
createRelayForOwner,
|
||||
getCreateRelayForMember,
|
||||
@@ -218,6 +224,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 qualified Simplex.FileTransfer.Description as FD
|
||||
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
|
||||
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
|
||||
import Simplex.Messaging.Agent.Store.Entity (DBEntityId)
|
||||
@@ -1209,17 +1216,35 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId}
|
||||
(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, GROwner)
|
||||
|
||||
-- Moderators and admins only, excluding owners and non-current members.
|
||||
-- Used for roster-related paths where owners must not be touched (owners are
|
||||
-- link-anchored), and left/removed members must not appear in the saved roster.
|
||||
-- The full roster set - members, moderators and admins - excluding owners (link-anchored) and
|
||||
-- left/removed members. For the privileged subset only use getGroupAdminsMods; for plain members
|
||||
-- only use getGroupOnlyMembers.
|
||||
getGroupRosterMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
|
||||
getGroupRosterMembers 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 IN (?,?,?)")
|
||||
(userId, groupId, userContactId, GRMember, GRModerator, GRAdmin)
|
||||
|
||||
-- Moderators and admins only (excluding owners and plain members) - the set introduced to a
|
||||
-- joiner; plain members are learned from the roster blob, not via introductions.
|
||||
getGroupAdminsMods :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
|
||||
getGroupAdminsMods 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 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)
|
||||
@@ -1411,35 +1436,114 @@ setGroupRosterVersion db GroupInfo {groupId} v = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId)
|
||||
|
||||
-- Relay saves the verbatim signed roster (parts + sending owner + broker ts) to re-forward to joiners.
|
||||
setGroupRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> SignedMsg -> IO ()
|
||||
setGroupRoster db GroupInfo {groupId} v ownerGMId brokerTs SignedMsg {chatBinding, signatures, signedBody} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[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))
|
||||
-- Persisted roster version (the gate baseline; the in-memory gInfo copy is batch-constant and stale on reorder).
|
||||
getGroupRosterVersion :: DB.Connection -> GroupInfo -> IO (Maybe VersionRoster)
|
||||
getGroupRosterVersion db GroupInfo {groupId} =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT roster_version FROM groups WHERE group_id = ?" (Only groupId)
|
||||
|
||||
getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg))
|
||||
-- The live roster header a relay re-serves to joiners, with the completed blob served alongside it
|
||||
-- (both are written together at completion, so the blob is present whenever the header is).
|
||||
getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg, Maybe ByteString))
|
||||
getGroupRoster db GroupInfo {groupId} =
|
||||
(>>= toRoster)
|
||||
<$> 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 = ?"
|
||||
"SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?"
|
||||
(Only groupId)
|
||||
)
|
||||
where
|
||||
toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body)) =
|
||||
(\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body)) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body), blob_) =
|
||||
(\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body, (\(Binary b) -> b) <$> blob_)) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
toRoster _ = Nothing
|
||||
|
||||
-- The signed-header columns are relay-only (Nothing on a member); promoted to the live header at
|
||||
-- completion so the relay can re-forward the roster.
|
||||
setRosterPending :: DB.Connection -> GroupInfo -> VersionRoster -> FD.FileDigest -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> IO ()
|
||||
setRosterPending db GroupInfo {groupId} v digest ownerGMId brokerTs sm_ = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups
|
||||
SET roster_pending_version = ?, roster_pending_digest = ?,
|
||||
roster_pending_sending_owner_gm_id = ?, roster_pending_broker_ts = ?,
|
||||
roster_pending_msg_chat_binding = ?, roster_pending_msg_signatures = ?, roster_pending_msg_body = ?,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
((v, Binary (FD.unFileDigest digest), ownerGMId, brokerTs)
|
||||
:. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_, currentTs, groupId))
|
||||
|
||||
getRosterPending :: DB.Connection -> GroupInfo -> IO (Maybe (VersionRoster, FD.FileDigest, GroupMemberId, UTCTime, Maybe SignedMsg))
|
||||
getRosterPending db GroupInfo {groupId} =
|
||||
(>>= toPending)
|
||||
<$> maybeFirstRow
|
||||
id
|
||||
( DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT roster_pending_version, roster_pending_digest,
|
||||
roster_pending_sending_owner_gm_id, roster_pending_broker_ts,
|
||||
roster_pending_msg_chat_binding, roster_pending_msg_signatures, roster_pending_msg_body
|
||||
FROM groups WHERE group_id = ?
|
||||
|]
|
||||
(Only groupId)
|
||||
)
|
||||
where
|
||||
toPending (Just v, Just (Binary d), Just ownerGMId, Just brokerTs, cb_, sigs_, body_) =
|
||||
Just (v, FD.FileDigest d, ownerGMId, brokerTs, sm_)
|
||||
where
|
||||
sm_ = case (cb_, sigs_, body_) of
|
||||
(Just cb, Just (Binary sigsBs), Just (Binary body)) ->
|
||||
(\sigs -> SignedMsg cb sigs body) <$> eitherToMaybe (smpDecode sigsBs)
|
||||
_ -> Nothing
|
||||
toPending _ = Nothing
|
||||
|
||||
-- Promote pending -> live (header + blob + version) and clear pending, in one statement.
|
||||
promoteRosterPending :: DB.Connection -> GroupInfo -> ByteString -> IO ()
|
||||
promoteRosterPending db GroupInfo {groupId} blob = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups SET
|
||||
roster_version = roster_pending_version,
|
||||
roster_blob = ?,
|
||||
roster_sending_owner_gm_id = roster_pending_sending_owner_gm_id,
|
||||
roster_broker_ts = roster_pending_broker_ts,
|
||||
roster_msg_chat_binding = roster_pending_msg_chat_binding,
|
||||
roster_msg_signatures = roster_pending_msg_signatures,
|
||||
roster_msg_body = roster_pending_msg_body,
|
||||
roster_pending_version = NULL,
|
||||
roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL,
|
||||
roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL,
|
||||
roster_pending_msg_signatures = NULL,
|
||||
roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
(Binary blob, currentTs, groupId)
|
||||
|
||||
clearRosterPending :: DB.Connection -> GroupInfo -> IO ()
|
||||
clearRosterPending db GroupInfo {groupId} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE groups SET
|
||||
roster_pending_version = NULL, roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL, roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL, roster_pending_msg_signatures = NULL, roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|]
|
||||
(currentTs, groupId)
|
||||
|
||||
setGroupMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO ()
|
||||
setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do
|
||||
currentTs <- getCurrentTime
|
||||
@@ -3174,11 +3278,11 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI
|
||||
where
|
||||
VersionRange minV maxV = vr cxt
|
||||
|
||||
-- member_pub_key is not updated here — introduced members are owners
|
||||
-- whose keys are loaded from link data (trusted out-of-band).
|
||||
-- Updating from an in-band message would allow a compromised relay to substitute keys.
|
||||
-- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative
|
||||
-- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from
|
||||
-- an in-band relayed intro would let a compromised relay substitute them.
|
||||
updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember
|
||||
updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do
|
||||
updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} = do
|
||||
_ <- updateMemberProfile db user member profile
|
||||
currentTs <- liftIO getCurrentTime
|
||||
liftIO $
|
||||
@@ -3186,14 +3290,13 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM
|
||||
db
|
||||
[sql|
|
||||
UPDATE group_members
|
||||
SET member_role = ?,
|
||||
member_status = ?,
|
||||
SET member_status = ?,
|
||||
peer_chat_min_version = ?,
|
||||
peer_chat_max_version = ?,
|
||||
updated_at = ?
|
||||
WHERE user_id = ? AND group_member_id = ?
|
||||
|]
|
||||
(memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId)
|
||||
(GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId)
|
||||
getGroupMemberById db cxt user groupMemberId
|
||||
where
|
||||
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
|
||||
|
||||
@@ -580,9 +580,9 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS
|
||||
CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ ->
|
||||
(Just $ Just userMemberId == memberId, memberId)
|
||||
|
||||
createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId
|
||||
createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs =
|
||||
createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing Nothing
|
||||
createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> IO ChatItemId
|
||||
createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgSigned itemTs =
|
||||
createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgSigned
|
||||
where
|
||||
quoteRow :: NewQuoteRow
|
||||
quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing)
|
||||
|
||||
@@ -15,11 +15,33 @@ 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;
|
||||
ALTER TABLE groups ADD COLUMN roster_blob BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_digest BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_body BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_signatures BYTEA;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_sending_owner_gm_id BIGINT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_broker_ts TIMESTAMPTZ;
|
||||
ALTER TABLE files ADD COLUMN shared_msg_id BYTEA;
|
||||
ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
|
||||
|]
|
||||
|
||||
down_m20260601_group_roster :: Text
|
||||
down_m20260601_group_roster =
|
||||
[r|
|
||||
DROP INDEX IF EXISTS idx_files_group_id_shared_msg_id;
|
||||
ALTER TABLE files DROP COLUMN file_type;
|
||||
ALTER TABLE files DROP COLUMN shared_msg_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_digest;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_version;
|
||||
ALTER TABLE groups DROP COLUMN roster_blob;
|
||||
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;
|
||||
|
||||
@@ -743,7 +743,9 @@ CREATE TABLE test_chat_schema.files (
|
||||
file_crypto_key bytea,
|
||||
file_crypto_nonce bytea,
|
||||
note_folder_id bigint,
|
||||
redirect_file_id bigint
|
||||
redirect_file_id bigint,
|
||||
shared_msg_id bytea,
|
||||
file_type text DEFAULT 'normal'::text NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -968,14 +970,22 @@ CREATE TABLE test_chat_schema.groups (
|
||||
public_member_count bigint,
|
||||
relay_request_retries bigint DEFAULT 0 NOT NULL,
|
||||
relay_request_delay bigint DEFAULT 0 NOT NULL,
|
||||
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL,
|
||||
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL,
|
||||
relay_inactive_at timestamp with time zone,
|
||||
roster_version integer,
|
||||
roster_msg_body bytea,
|
||||
roster_msg_chat_binding text,
|
||||
roster_msg_signatures bytea,
|
||||
roster_sending_owner_gm_id bigint,
|
||||
roster_broker_ts timestamp with time zone
|
||||
roster_broker_ts timestamp with time zone,
|
||||
roster_blob bytea,
|
||||
roster_pending_version integer,
|
||||
roster_pending_digest bytea,
|
||||
roster_pending_msg_body bytea,
|
||||
roster_pending_msg_chat_binding text,
|
||||
roster_pending_msg_signatures bytea,
|
||||
roster_pending_sending_owner_gm_id bigint,
|
||||
roster_pending_broker_ts timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
@@ -2268,6 +2278,10 @@ CREATE INDEX idx_files_group_id ON test_chat_schema.files USING btree (group_id)
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON test_chat_schema.files USING btree (group_id, shared_msg_id);
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_files_redirect_file_id ON test_chat_schema.files USING btree (redirect_file_id);
|
||||
|
||||
|
||||
|
||||
@@ -14,11 +14,33 @@ 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;
|
||||
ALTER TABLE groups ADD COLUMN roster_blob BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_version INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_digest BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_body BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_chat_binding TEXT;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_msg_signatures BLOB;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_sending_owner_gm_id INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN roster_pending_broker_ts TEXT;
|
||||
ALTER TABLE files ADD COLUMN shared_msg_id BLOB;
|
||||
ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
|
||||
|]
|
||||
|
||||
down_m20260601_group_roster :: Query
|
||||
down_m20260601_group_roster =
|
||||
[sql|
|
||||
DROP INDEX IF EXISTS idx_files_group_id_shared_msg_id;
|
||||
ALTER TABLE files DROP COLUMN file_type;
|
||||
ALTER TABLE files DROP COLUMN shared_msg_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_broker_ts;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_sending_owner_gm_id;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_signatures;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_chat_binding;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_msg_body;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_digest;
|
||||
ALTER TABLE groups DROP COLUMN roster_pending_version;
|
||||
ALTER TABLE groups DROP COLUMN roster_blob;
|
||||
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;
|
||||
|
||||
@@ -548,15 +548,6 @@ Plan:
|
||||
SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?)
|
||||
SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?)
|
||||
|
||||
Query:
|
||||
UPDATE rcv_messages
|
||||
SET receive_attempts = receive_attempts + 1
|
||||
WHERE conn_id = ? AND internal_id = ?
|
||||
RETURNING receive_attempts
|
||||
|
||||
Plan:
|
||||
SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
|
||||
|
||||
Query:
|
||||
DELETE FROM conn_confirmations
|
||||
WHERE conn_id = ?
|
||||
|
||||
@@ -1051,6 +1051,15 @@ Query:
|
||||
Plan:
|
||||
SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=? AND ldn_base=?)
|
||||
|
||||
Query:
|
||||
SELECT roster_pending_version, roster_pending_digest,
|
||||
roster_pending_sending_owner_gm_id, roster_pending_broker_ts,
|
||||
roster_pending_msg_chat_binding, roster_pending_msg_signatures, roster_pending_msg_body
|
||||
FROM groups WHERE group_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT s.contact_id, g.group_id, s.group_member_id
|
||||
FROM sent_probes s
|
||||
@@ -1238,8 +1247,8 @@ Query:
|
||||
INSERT INTO groups
|
||||
(use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs,
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at,
|
||||
root_priv_key, root_pub_key, member_priv_key, public_member_count)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
|
||||
@@ -1625,7 +1634,7 @@ Query:
|
||||
SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name,
|
||||
f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name,
|
||||
f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline,
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type
|
||||
FROM rcv_files r
|
||||
JOIN files f USING (file_id)
|
||||
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
|
||||
@@ -3659,6 +3668,15 @@ Plan:
|
||||
SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=?)
|
||||
SEARCH f USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?)
|
||||
|
||||
Query:
|
||||
SELECT f.file_id FROM files f
|
||||
JOIN rcv_files r ON r.file_id = f.file_id
|
||||
WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH f USING INDEX idx_files_group_id_shared_msg_id (group_id=? AND shared_msg_id=?)
|
||||
SEARCH r USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT file_id, contact_id, group_id, note_folder_id
|
||||
FROM files
|
||||
@@ -5140,14 +5158,6 @@ Query:
|
||||
Plan:
|
||||
SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?)
|
||||
|
||||
Query:
|
||||
UPDATE group_relays
|
||||
SET relay_status = ?, updated_at = ?
|
||||
WHERE group_member_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?)
|
||||
|
||||
Query:
|
||||
UPDATE group_snd_item_statuses
|
||||
SET group_snd_item_status = ?, updated_at = ?
|
||||
@@ -5192,8 +5202,43 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
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 = ?
|
||||
SET roster_pending_version = ?, roster_pending_digest = ?,
|
||||
roster_pending_sending_owner_gm_id = ?, roster_pending_broker_ts = ?,
|
||||
roster_pending_msg_chat_binding = ?, roster_pending_msg_signatures = ?, roster_pending_msg_body = ?,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE groups SET
|
||||
roster_pending_version = NULL, roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL, roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL, roster_pending_msg_signatures = NULL, roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE groups SET
|
||||
roster_version = roster_pending_version,
|
||||
roster_blob = ?,
|
||||
roster_sending_owner_gm_id = roster_pending_sending_owner_gm_id,
|
||||
roster_broker_ts = roster_pending_broker_ts,
|
||||
roster_msg_chat_binding = roster_pending_msg_chat_binding,
|
||||
roster_msg_signatures = roster_pending_msg_signatures,
|
||||
roster_msg_body = roster_pending_msg_body,
|
||||
roster_pending_version = NULL,
|
||||
roster_pending_digest = NULL,
|
||||
roster_pending_sending_owner_gm_id = NULL,
|
||||
roster_pending_broker_ts = NULL,
|
||||
roster_pending_msg_chat_binding = NULL,
|
||||
roster_pending_msg_signatures = NULL,
|
||||
roster_pending_msg_body = NULL,
|
||||
updated_at = ?
|
||||
WHERE group_id = ?
|
||||
|
||||
Plan:
|
||||
@@ -5216,6 +5261,14 @@ Query:
|
||||
Plan:
|
||||
SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE rcv_file_chunks
|
||||
SET chunk_stored = 1, updated_at = ?
|
||||
WHERE file_id = ? AND chunk_number = ?
|
||||
|
||||
Plan:
|
||||
SEARCH rcv_file_chunks USING PRIMARY KEY (file_id=? AND chunk_number=?)
|
||||
|
||||
Query:
|
||||
UPDATE rcv_files
|
||||
SET to_receive = 1, user_approved_relays = ?, updated_at = ?
|
||||
@@ -6429,6 +6482,14 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
|
||||
SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?)
|
||||
|
||||
Query: DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?
|
||||
Plan:
|
||||
SEARCH files USING INDEX idx_files_group_id (group_id=?)
|
||||
SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?)
|
||||
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
|
||||
SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?)
|
||||
|
||||
Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ?
|
||||
Plan:
|
||||
SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
|
||||
@@ -6681,7 +6742,7 @@ Plan:
|
||||
Query: INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
@@ -6736,6 +6797,9 @@ Plan:
|
||||
Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1
|
||||
Plan:
|
||||
SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?)
|
||||
@@ -6871,6 +6935,10 @@ Query: SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ?
|
||||
Plan:
|
||||
SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_group_id (group_id=?)
|
||||
|
||||
Query: SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1
|
||||
Plan:
|
||||
SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_file_id (file_id=?)
|
||||
|
||||
Query: SELECT conn_req_inv FROM connections WHERE connection_id = ?
|
||||
Plan:
|
||||
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
|
||||
@@ -6935,6 +7003,10 @@ Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?
|
||||
Plan:
|
||||
SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?)
|
||||
|
||||
Query: SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?
|
||||
Plan:
|
||||
SEARCH files USING INDEX idx_files_group_id (group_id=?)
|
||||
|
||||
Query: SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?
|
||||
Plan:
|
||||
SEARCH g USING INTEGER PRIMARY KEY (rowid=?)
|
||||
@@ -7003,6 +7075,14 @@ Query: SELECT max(active_order) FROM users
|
||||
Plan:
|
||||
SEARCH users
|
||||
|
||||
Query: SELECT member_id FROM group_members WHERE local_display_name = ?
|
||||
Plan:
|
||||
SCAN group_members
|
||||
|
||||
Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ?
|
||||
Plan:
|
||||
SCAN group_members
|
||||
|
||||
Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ?
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
@@ -7055,10 +7135,18 @@ Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ?
|
||||
Plan:
|
||||
SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body FROM groups WHERE group_id = ?
|
||||
Query: SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL
|
||||
Plan:
|
||||
SCAN groups
|
||||
|
||||
Query: SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: SELECT roster_version FROM groups
|
||||
Plan:
|
||||
SCAN groups
|
||||
|
||||
Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
@@ -7303,6 +7391,10 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: UPDATE group_members SET member_role = ?, member_pub_key = NULL WHERE local_display_name = ?
|
||||
Plan:
|
||||
SCAN group_members
|
||||
|
||||
Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
@@ -7383,6 +7475,10 @@ Query: UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? W
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL
|
||||
Plan:
|
||||
SCAN groups
|
||||
|
||||
Query: UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
@@ -188,7 +188,15 @@ CREATE TABLE groups(
|
||||
roster_msg_chat_binding TEXT,
|
||||
roster_msg_signatures BLOB,
|
||||
roster_sending_owner_gm_id INTEGER,
|
||||
roster_broker_ts TEXT, -- received
|
||||
roster_broker_ts TEXT,
|
||||
roster_blob BLOB,
|
||||
roster_pending_version INTEGER,
|
||||
roster_pending_digest BLOB,
|
||||
roster_pending_msg_body BLOB,
|
||||
roster_pending_msg_chat_binding TEXT,
|
||||
roster_pending_msg_signatures BLOB,
|
||||
roster_pending_sending_owner_gm_id INTEGER,
|
||||
roster_pending_broker_ts TEXT, -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -274,7 +282,9 @@ CREATE TABLE files(
|
||||
file_crypto_key BLOB,
|
||||
file_crypto_nonce BLOB,
|
||||
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
|
||||
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
|
||||
redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE,
|
||||
shared_msg_id BLOB,
|
||||
file_type TEXT NOT NULL DEFAULT 'normal'
|
||||
) STRICT;
|
||||
CREATE TABLE snd_files(
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
@@ -1313,6 +1323,10 @@ ON groups(
|
||||
relay_request_group_link
|
||||
)
|
||||
WHERE relay_request_group_link IS NOT NULL;
|
||||
CREATE INDEX idx_files_group_id_shared_msg_id ON files(
|
||||
group_id,
|
||||
shared_msg_id
|
||||
);
|
||||
CREATE TRIGGER on_group_members_insert_update_summary
|
||||
AFTER INSERT ON group_members
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -973,6 +973,11 @@ newtype MemberKey = MemberKey C.PublicKeyEd25519
|
||||
deriving (Eq, Show)
|
||||
deriving newtype (StrEncoding)
|
||||
|
||||
-- Binary encoding for the roster blob; delegates to the Ed25519 key.
|
||||
instance Encoding MemberKey where
|
||||
smpEncode (MemberKey k) = smpEncode k
|
||||
smpP = MemberKey <$> smpP
|
||||
|
||||
instance FromJSON MemberKey where
|
||||
parseJSON = strParseJSON "MemberKey"
|
||||
|
||||
@@ -1494,11 +1499,38 @@ instance ToJSON InlineFileMode where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
-- Discriminates ordinary chat files from the roster blob file, so the receive
|
||||
-- completion / cancel paths branch on the type rather than on chat_item_id (note
|
||||
-- folders and redirects also lack a chat item).
|
||||
data FileType = FTNormal | FTRoster
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance TextEncoding FileType where
|
||||
textEncode = \case
|
||||
FTNormal -> "normal"
|
||||
FTRoster -> "roster"
|
||||
textDecode = \case
|
||||
"normal" -> Just FTNormal
|
||||
"roster" -> Just FTRoster
|
||||
_ -> Nothing
|
||||
|
||||
instance FromField FileType where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField FileType where toField = toField . textEncode
|
||||
|
||||
instance FromJSON FileType where
|
||||
parseJSON = textParseJSON "FileType"
|
||||
|
||||
instance ToJSON FileType where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
data RcvFileTransfer = RcvFileTransfer
|
||||
{ fileId :: FileTransferId,
|
||||
xftpRcvFile :: Maybe XFTPRcvFile,
|
||||
fileInvitation :: FileInvitation,
|
||||
fileStatus :: RcvFileStatus,
|
||||
fileType :: FileType,
|
||||
rcvFileInline :: Maybe InlineFileMode,
|
||||
senderDisplayName :: ContactName,
|
||||
chunkSize :: Integer,
|
||||
|
||||
@@ -11,6 +11,7 @@ import qualified Data.ByteString.Char8 as B
|
||||
import Data.Text (Text)
|
||||
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
|
||||
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (dropPrefix, enumJSON)
|
||||
import Simplex.Messaging.Util ((<$?>))
|
||||
@@ -57,6 +58,12 @@ instance ToJSON GroupMemberRole where
|
||||
toJSON = textToJSON
|
||||
toEncoding = textToEncoding
|
||||
|
||||
-- Binary encoding for the roster blob; delegates to the canonical TextEncoding
|
||||
-- (same member/moderator/admin form JSON and the DB use). GRUnknown round-trips.
|
||||
instance Encoding GroupMemberRole where
|
||||
smpEncode = smpEncode . textEncode
|
||||
smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP
|
||||
|
||||
data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show)
|
||||
|
||||
instance StrEncoding GroupAcceptance where
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ testCfg =
|
||||
shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"],
|
||||
testView = True,
|
||||
tbqSize = 16,
|
||||
channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages
|
||||
channelSubscriberRole = GRObserver,
|
||||
confirmMigrations = MCYesUp
|
||||
}
|
||||
|
||||
|
||||
+412
-168
File diff suppressed because it is too large
Load Diff
@@ -278,7 +278,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
#==# XGrpMemConAll (MemberId "\1\2\3\4")
|
||||
it "x.grp.mem.del" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||
#==# XGrpMemDel (MemberId "\1\2\3\4") False
|
||||
#==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing
|
||||
it "x.grp.leave" $
|
||||
"{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}"
|
||||
==# XGrpLeave
|
||||
|
||||
Reference in New Issue
Block a user