implement, test

This commit is contained in:
Evgeny @ SimpleX Chat
2026-04-13 15:23:52 +00:00
parent 9ee3fa104d
commit d070a75f11
12 changed files with 132 additions and 8 deletions
@@ -114,7 +114,7 @@ groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary
fromMaybe (img, ".jpg") $
(,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img
<|> (,".png") <$> T.stripPrefix "data:image/png;base64," img
imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) $ strEncode (connFullLink groupLink)
imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) $ strEncode (let CCLink fl _ = groupLink in fl)
imgFile = listingImageFolder </> imgName <> imgExt
in case B64.decode $ encodeUtf8 img' of
Right img'' -> Just (imgFile, img'')
+1
View File
@@ -282,6 +282,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
Simplex.Chat.Store.SQLite.Migrations.M20260413_chat_hidden
other-modules:
Paths_simplex_chat
hs-source-dirs:
+3
View File
@@ -376,6 +376,7 @@ data ChatCommand
| APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString}
| APIGetConnNtfMessages (NonEmpty ConnMsgReq)
| APIAddMember {groupId :: GroupId, contactId :: ContactId, memberRole :: GroupMemberRole}
| APISharePublicGroup {groupId :: GroupId, toChatRef :: ChatRef}
| APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter}
| APIAcceptMember {groupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole}
| APIDeleteMemberSupportChat GroupId GroupMemberId
@@ -518,6 +519,7 @@ data ChatCommand
| APIGetGroupRelays {groupId :: GroupId}
| NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile
| AddMember GroupName ContactName GroupMemberRole
| SharePublicGroup GroupName ContactName
| JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter}
| AcceptMember GroupName ContactName GroupMemberRole
| MemberRole GroupName ContactName GroupMemberRole
@@ -732,6 +734,7 @@ data ChatResponse
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
| CRGroupsList {user :: User, groups :: [GroupInfo]}
| CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember}
| CRSentPublicGroupInvitation {user :: User, groupInfo :: GroupInfo}
| CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus
| CRFileTransferStatusXFTP User AChatItem
| CRUserProfile {user :: User, profile :: Profile}
+37
View File
@@ -101,6 +101,7 @@ import qualified Simplex.Messaging.Crypto.ShortLink as SL
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOff, pattern PQSupportOff, pattern PQSupportOn)
import Simplex.Messaging.Encoding (smpEncode)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol)
@@ -2470,6 +2471,37 @@ processChatCommand vr nm = \case
relays <- liftIO $ getGroupRelays db gInfo
pure (gInfo, relays)
pure $ CRGroupRelays user gInfo relays
APISharePublicGroup groupId toChatRef -> withUser $ \user -> do
gInfo@GroupInfo {groupProfile, membership, groupSummary, localDisplayName = gName} <- withFastStore $ \db -> getGroupInfo db vr user groupId
unless (useRelays' gInfo) $ throwCmdError "not a public group"
let groupSize = fromIntegral $ currentMembers groupSummary
GroupMember {memberId = mId, groupMemberId = mGroupMemberId} = membership
ciGroupInv oss = CIGroupInvitation {groupId, groupMemberId = mGroupMemberId, localDisplayName = gName, groupProfile, status = CIGISPending, ownerSigStatus = oss}
case toChatRef of
ChatRef CTDirect contactId _ -> do
ct@Contact {activeConn = Just ctConn} <- withFastStore $ \db -> getContact db vr user contactId
groupOwner <- forM (groupKeys gInfo) $ \GroupKeys {memberPrivKey} -> do
adHash <- withAgent $ \a -> getConnectionRatchetAdHash a (aConnId ctConn)
let invBody = encodeUtf8 $ encodeJSON groupProfile
sig = C.sign' memberPrivKey $ smpEncode CBDirect <> adHash <> invBody
pure GroupOwnerSig {memberId = mId, binding = B64UrlByteString adHash, ownerSig = B64UrlByteString $ C.signatureBytes sig}
let inv = PublicGroupInvitation {groupProfile, groupOwner, groupSize}
oss = if isJust groupOwner then Just OSSVerified else Nothing
(msg, _) <- sendDirectContactMessage user ct $ XGrpInvPub inv
ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndGroupInvitation (ciGroupInv oss) GRObserver) Nothing Nothing Nothing Nothing False
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
pure $ CRSentPublicGroupInvitation user gInfo
ChatRef CTGroup toGroupId scope -> do
(toGInfo, members) <- withFastStore $ \db -> do
g <- getGroupInfo db vr user toGroupId
ms <- liftIO $ getGroupMembers db vr user g
pure (g, ms)
let inv = PublicGroupInvitation {groupProfile, groupOwner = Nothing, groupSize}
msg <- sendGroupMessage user toGInfo scope members $ XGrpInvPub inv
ci <- saveSndChatItem' user (CDGroupSnd toGInfo Nothing) msg (CISndGroupInvitation (ciGroupInv Nothing) GRObserver) Nothing Nothing Nothing Nothing False
toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat toGInfo Nothing) ci]
pure $ CRSentPublicGroupInvitation user gInfo
_ -> throwCmdError "unsupported chat type"
APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do
-- TODO for large groups: no need to load all members to determine if contact is a member
(group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId
@@ -2857,6 +2889,9 @@ processChatCommand vr nm = \case
AddMember gName cName memRole -> withUser $ \user -> do
(groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName
processChatCommand vr nm $ APIAddMember groupId contactId memRole
SharePublicGroup gName cName -> withUser $ \user -> do
(groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName
processChatCommand vr nm $ APISharePublicGroup groupId (ChatRef CTDirect contactId Nothing)
JoinGroup gName enableNtfs -> withUser $ \user -> do
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
processChatCommand vr nm $ APIJoinGroup groupId enableNtfs
@@ -4809,6 +4844,8 @@ chatCommandP =
"/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP),
"/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP),
"/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
"/_share #" *> (APISharePublicGroup <$> A.decimal <* A.space <*> chatRefP),
"/share " *> char_ '#' *> (SharePublicGroup <$> displayNameP <* A.space <* char_ '@' <*> displayNameP),
"/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI
"/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
"/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal),
+37
View File
@@ -519,6 +519,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
XInfo p -> xInfo ct'' p
XDirectDel -> xDirectDel ct'' msg msgMeta
XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta
XGrpInvPub pubGrpInv -> processPublicGroupInvitation ct'' conn' pubGrpInv msg msgMeta
XInfoProbe probe -> xInfoProbe (COMContact ct'') probe
XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct'') probeHash
XInfoProbeOk probe -> xInfoProbeOk (COMContact ct'') probe
@@ -675,6 +676,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
forM_ (autoReply $ addressSettings ucl) $ \mc -> do
connReq_ <- pure (contactRequestId' ct) $>>= \connReqId -> withStore' (\db -> getContactRequest' db user connReqId)
sendAutoReply ct mc connReq_
LDATA fixedLinkData cData ->
withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} ->
case cmdFunction of
CFGetGroupDataInv -> processPublicGroupLinkData user ct conn fixedLinkData cData
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
QCONT ->
void $ continueSending connEntity conn
MWARN msgId err -> do
@@ -2408,6 +2414,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
sameGroupLinkId (Just gli) (Just gli') = gli == gli'
sameGroupLinkId _ _ = False
processPublicGroupInvitation :: Contact -> Connection -> PublicGroupInvitation -> RcvMessage -> MsgMeta -> CM ()
processPublicGroupInvitation ct conn PublicGroupInvitation {groupProfile, groupOwner, groupSize} msg msgMeta = do
unless (isJust $ publicGroup groupProfile) $ messageError "x.grp.inv.pub: not a public group"
let oss = case groupOwner of
Nothing -> Nothing
Just _ -> Just OSSPending
sLnk_ = (\PublicGroupProfile {groupLink = gl} -> gl) <$> publicGroup groupProfile
prepLink = PreparedConnLink {connFullLink = Nothing, connShortLink = sLnk_}
gVar <- asks random
subRole <- asks $ channelSubscriberRole . config
(gInfo@GroupInfo {groupId, localDisplayName = gName, membership}, _) <- withStore $ \db ->
createPreparedGroup db gVar vr user groupProfile False prepLink Nothing True subRole (Just $ fromIntegral groupSize)
withStore' $ \db -> setGroupChatHidden db user groupId True
let GroupMember {groupMemberId} = membership
content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName = gName, groupProfile, status = CIGISPending, ownerSigStatus = oss}) GRObserver
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = GROwner, memberRole = GRObserver}
-- start async link resolution for signature verification
forM_ groupOwner $ \_ ->
forM_ sLnk_ $ \sLnk ->
void $ getAgentConnShortLinkAsync user CFGetGroupDataInv (Just conn) sLnk
where
brokerTs = metaBrokerTs msgMeta
processPublicGroupLinkData :: User -> Contact -> Connection -> FixedLinkData 'CMContact -> ConnLinkData 'CMContact -> CM ()
processPublicGroupLinkData _user _ct _conn _fixedLinkData _cData = do
-- TODO: verify owner signature, update chat item ownerSigStatus
pure ()
checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> CM ()
checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of
MsgOk -> pure ()
+6
View File
@@ -40,6 +40,7 @@ module Simplex.Chat.Store.Groups
createGroupInvitedViaLink,
createGroupRejectedViaLink,
setGroupInvitationChatItemId,
setGroupChatHidden,
getGroup,
getGroupInfoByUserContactLinkConnReq,
getGroupInfoViaUserShortLink,
@@ -879,6 +880,11 @@ setGroupInvitationChatItemId db User {userId} groupId chatItemId = do
currentTs <- getCurrentTime
DB.execute db "UPDATE groups SET chat_item_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (chatItemId, currentTs, userId, groupId)
setGroupChatHidden :: DB.Connection -> User -> GroupId -> Bool -> IO ()
setGroupChatHidden db User {userId} groupId hidden = do
currentTs <- getCurrentTime
DB.execute db "UPDATE groups SET chat_hidden = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (BI hidden, currentTs, userId, groupId)
-- TODO return the last connection that is ready, not any last connection
-- requires updating connection status
getGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO Group
+5 -5
View File
@@ -915,7 +915,7 @@ findGroupChatPreviews_ db User {userId} pagination clq =
baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False)
getPreviews = case clq of
CLQFilters {favorite = False, unread = False} -> do
let q = baseQuery <> " WHERE g.user_id = ? AND g.creating_in_progress = 0"
let q = baseQuery <> " WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_hidden = 0"
p = baseParams :. Only userId
queryWithPagination q p
CLQFilters {favorite = True, unread = False} -> do
@@ -923,7 +923,7 @@ findGroupChatPreviews_ db User {userId} pagination clq =
baseQuery
<> " "
<> [sql|
WHERE g.user_id = ? AND g.creating_in_progress = 0
WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_hidden = 0
AND g.favorite = 1
|]
p = baseParams :. Only userId
@@ -933,7 +933,7 @@ findGroupChatPreviews_ db User {userId} pagination clq =
baseQuery
<> " "
<> [sql|
WHERE g.user_id = ? AND g.creating_in_progress = 0
WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_hidden = 0
AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0)
|]
p = baseParams :. Only userId
@@ -943,7 +943,7 @@ findGroupChatPreviews_ db User {userId} pagination clq =
baseQuery
<> " "
<> [sql|
WHERE g.user_id = ? AND g.creating_in_progress = 0
WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_hidden = 0
AND (g.favorite = 1
OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0)
|]
@@ -955,7 +955,7 @@ findGroupChatPreviews_ db User {userId} pagination clq =
<> " "
<> [sql|
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
WHERE g.user_id = ? AND g.creating_in_progress = 0
WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_hidden = 0
AND (
LOWER(g.local_display_name) LIKE '%' || ? || '%'
OR LOWER(gp.display_name) LIKE '%' || ? || '%'
+3 -1
View File
@@ -151,6 +151,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices
import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
import Simplex.Chat.Store.SQLite.Migrations.M20260413_chat_hidden
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -301,7 +302,8 @@ schemaMigrations =
("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices),
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link),
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed)
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("20260413_chat_hidden", m20260413_chat_hidden, Just down_m20260413_chat_hidden)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260413_chat_hidden where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260413_chat_hidden :: Query
m20260413_chat_hidden =
[sql|
ALTER TABLE groups ADD COLUMN chat_hidden INTEGER NOT NULL DEFAULT 0;
|]
down_m20260413_chat_hidden :: Query
down_m20260413_chat_hidden =
[sql|
ALTER TABLE groups DROP COLUMN chat_hidden;
|]
+2 -1
View File
@@ -922,7 +922,8 @@ data GroupOwnerSig = GroupOwnerSig
-- | Context binding: ratchetAdHash (direct chat) or smpEncode (publicGroupId, memberId) (public group).
-- Verifier computes expected value from context and compares — mismatch indicates MitM or wrong context.
binding :: B64UrlByteString,
ownerSig :: C.Signature 'C.Ed25519
-- | Signature bytes, base64url encoded.
ownerSig :: B64UrlByteString
}
deriving (Eq, Show)
+1
View File
@@ -187,6 +187,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
-- CRGroupConversationsDeleted u _g _conversations -> ttyUser u []
CRGroupsList u gs -> ttyUser u $ viewGroupsList gs
CRSentGroupInvitation u g c _ -> ttyUser u $ viewSentGroupInvitation g c
CRSentPublicGroupInvitation u g -> ttyUser u ["shared public group " <> ttyGroup' g]
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci
CRUserProfile u p -> ttyUser u $ viewUserProfile p
+18
View File
@@ -14,6 +14,8 @@ chatRelayTests = do
it "re-add soft-deleted relay by same name" testReAddRelaySameName
it "test chat relay" testChatRelayTest
it "relay profile updated in address" testRelayProfileUpdateInAddress
describe "public group invitations" $ do
it "share public group in direct chat" testSharePublicGroupDirect
testGetSetChatRelays :: HasCallStack => TestParams -> IO ()
testGetSetChatRelays ps =
@@ -165,6 +167,22 @@ testRelayProfileUpdateInAddress ps =
alice ##> ("/relay test " <> bobSLink)
alice <## "relay test passed, profile: bob2 (Bob relay)"
testSharePublicGroupDirect :: HasCallStack => TestParams -> IO ()
testSharePublicGroupDirect ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChat ps "cath" cathProfile $ \cath -> do
bob ##> "/ad"
(bobSLink, _cLink) <- getContactLinks bob True
alice ##> ("/relays name=bob_relay " <> bobSLink)
alice <## "ok"
createChannelWithRelay "team" alice bob
connectUsers alice cath
alice ##> "/share #team @cath"
alice <## "shared public group #team"
cath <## "#team: alice invites you to join the group as observer"
cath <## "use /j team to accept"
-- Create a public group with relay=1, wait for relay to join
createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO ()
createChannelWithRelay gName owner relay = do