diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index 0d4e8d351c..93fd0eda5f 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -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'') diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 320e51c8af..7e83f8d5f7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -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: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 16652f90dd..7393fc78b6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d52868d4bb..842acec15a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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), diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 582e6e0411..22bf21ca9b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -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 () diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 71ea9d2d6d..cce63637fe 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index a2c91af86b..8c9369a993 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -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 '%' || ? || '%' diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 607e0549b1..25e1cfafa5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -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 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260413_chat_hidden.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260413_chat_hidden.hs new file mode 100644 index 0000000000..a4fd7efae2 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260413_chat_hidden.hs @@ -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; +|] diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index bde932e632..7a74919334 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -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) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 3afe0ce0ce..ab46713991 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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 diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 721d71d0e0..175be949b7 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -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