Merge branch 'master' into badges

This commit is contained in:
spaced4ndy
2026-09-02 13:53:35 +04:00
114 changed files with 13939 additions and 1052 deletions
+5 -13
View File
@@ -28,8 +28,8 @@ import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Time.Clock (getCurrentTime, nominalDay)
import Simplex.Chat.Badges (badgeServerCredential)
import Simplex.Chat.Controller
import Simplex.Chat.Badges (BBSPublicKeyStr (..))
import Simplex.Chat.Library.Commands
import Simplex.Chat.Operators
import Simplex.Chat.Operators.Presets
@@ -52,6 +52,7 @@ import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError)
import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.Entitlement (entitlementIssuerKeys)
import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol)
import qualified Simplex.Messaging.TMap as TM
import qualified UnliftIO.Exception as E
@@ -66,17 +67,7 @@ defaultChatConfig =
tbqSize = 1024
},
chatVRange = supportedChatVRange,
badgePublicKeys =
M.fromList
[ (1, toBBSPublicKey "mW_5Zp1wHnXDF56wOZwFcRjGrf0GLLsfyymIQDqYoWfjfvS7oQWSfi7hH65N8JhuE9x8wbKXHidnQLO4GnOSMP_bRKUMH1qIzv5SQKFHNM8G4PaWcTcri8iZLc-3xhSI"),
(2, toBBSPublicKey "odGCB7uVDXTURsHgSvSciByV4Q3-3ZvEB8myDsDJqm-PwOYc5-At36uc7n_pyUDxEQEHr9i4RJgFih2FSArPW-EQBXNPNf4wTtA0znn74qLEGc4fh9pVYPEIm_ZGbnsJ"),
(3, toBBSPublicKey "txkT2003WMjc43KvYvPKEcR970NLmw5UZY51eUqgk91sgp53idt1HTlKYvnrEttJDFMlctYf1-bpri0e9DhBQ-xk1J4WoLN2uif_1OcA1pGCobpk9lwtsq1Idek4biy0"),
(4, toBBSPublicKey "q_YzegihaLYrEm9z3cAghsfDGNZfXuEpQGMJERJQS4M0Szl4gvSC_fV_muKc3NIMA_8iYuBN8qyvb5U55RctCRn3kleFQ4sqf-WBgoydX6UVo7BsYcUbXWWEFZXlOGIH"),
(5, toBBSPublicKey "oqymHASH_okefShrnz4HnTooUNlE1WoDRnSrgd0bTCpOacgJWBsMpwZpdmYlX-vQAKAC_zmI4VdKoOznnhW-sdUXZw6bthCi5JYjGxCR1Co27i1tix5UXCTbR5Jp901-"),
(6, toBBSPublicKey "kDqaB6zKSRp_97QPFj5JPDlo0vzfSTLSp9goFx1qajv4q4H6dR6BbkmWZ4xx_9Q2AxmcpqcV0ethz1OH-Jk_Sz2J1mIz1PUVM9LkdLhi_PNtqhezzO5dbVs-HJ1fNqe6"),
(7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"),
(8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd")
],
badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys,
badgeServiceAddress = Nothing,
confirmMigrations = MCConsole,
-- this property should NOT use operator = Nothing
@@ -293,7 +284,8 @@ newChatController
let opDomains = operatorDomains $ mapMaybe snd ops
(smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users
let useServices = M.fromList $ map (\User {agentUserId = AgentUserId uId, clientService} -> (uId, isTrue clientService)) users
pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, useServices, presetDomains, presetServers = L.toList allPresetServers}
entitlements = M.fromList $ mapMaybe (\User {agentUserId = AgentUserId uId, profile = LocalProfile {localBadge}} -> (uId,) <$> badgeServerCredential localBadge) users
pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), entitlements, ntf, netCfg, useServices, presetDomains, presetServers = L.toList allPresetServers}
where
optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))]
optServers srvs overrides_ = case L.nonEmpty overrides_ of
+26 -17
View File
@@ -26,6 +26,7 @@ module Simplex.Chat.Badges
localBadgeInfo,
localBadgeStatus,
maxXFTPFileSize,
badgeServerCredential,
maxFileSizeSupporter,
maxFileSizeLegend,
ProofPresHeaderTag (..),
@@ -69,6 +70,7 @@ import Simplex.FileTransfer.Description (gb, maxFileSize)
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..), fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS
import Simplex.Messaging.Crypto.Entitlement (Entitlement (Entitlement), EntitlementCredential (EntitlementCredential), MasterKey (MasterKey), entitlementBBSHeader)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
#if defined(dbPostgres)
@@ -116,26 +118,29 @@ data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey
data BadgeInfo = BadgeInfo
{ badgeType :: BadgeType,
badgeExpiry :: Maybe UTCTime,
badgeExpiry :: UTCTime,
badgeExtra :: Text
}
deriving (Eq, Show)
-- a badge stays active for this long after its expiry, to allow for a delayed renewal
badgeGraceInterval :: NominalDiffTime
badgeGraceInterval = 7 * nominalDay
-- a badge expired longer than this ago is BSExpiredOld and is not shown in the UI
badgeOldInterval :: NominalDiffTime
badgeOldInterval = 31 * nominalDay
badgeOldInterval = badgeGraceInterval + 31 * nominalDay
-- the verification outcome of a received proof: Just True = verified, Just False = failed,
-- Nothing = the proof's key index is not among this app version's configured keys (BSUnknownKey).
mkBadgeStatus :: UTCTime -> Maybe Bool -> BadgeInfo -> BadgeStatus
mkBadgeStatus now verified BadgeInfo {badgeExpiry} = case verified of
mkBadgeStatus now verified BadgeInfo {badgeExpiry = e} = case verified of
Nothing -> BSUnknownKey
Just False -> BSFailed
Just True -> case badgeExpiry of
Just e
| addUTCTime badgeOldInterval e < now -> BSExpiredOld
| e < now -> BSExpired
_ -> BSActive
Just True
| addUTCTime badgeOldInterval e < now -> BSExpiredOld
| addUTCTime badgeGraceInterval e < now -> BSExpired
| otherwise -> BSActive
-- A badge credential (own, secret) and a proof (a presentation) are independent records.
-- badgeKeyIdx is the issuer key index: it tells verifiers which configured key to use.
@@ -187,6 +192,12 @@ maxFileSizeSupporter = gb 2
maxFileSizeLegend :: Int64
maxFileSizeLegend = gb 5
badgeServerCredential :: Maybe LocalBadge -> Maybe EntitlementCredential
badgeServerCredential = \case
Just (OwnBadge (BadgeCredential idx (BadgeMasterKey mk) sig BadgeInfo {badgeType, badgeExpiry, badgeExtra}) _) ->
Just $ EntitlementCredential (fromIntegral idx) (MasterKey mk) (Entitlement badgeExpiry (textEncode badgeType) badgeExtra) sig
_ -> Nothing
maxXFTPFileSize :: Maybe LocalBadge -> Int64
maxXFTPFileSize = \case
Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of
@@ -269,7 +280,7 @@ newtype VerifiedBadgeRequest = VerifiedBadgeRequest BadgeRequest
-- Constants
bbsBadgeHeader :: BBSHeader
bbsBadgeHeader = BBSHeader "SimpleX badges v1"
bbsBadgeHeader = entitlementBBSHeader
bbsBadgeMessageCount :: Int
bbsBadgeMessageCount = 4
@@ -279,15 +290,12 @@ bbsBadgeDisclosedIndexes = [1, 2, 3]
-- Message encoding
encodeExpiry :: Maybe UTCTime -> ByteString
encodeExpiry = maybe "lifetime" strEncode
badgeMessages :: BadgeMasterKey -> BadgeInfo -> [ByteString]
badgeMessages (BadgeMasterKey ms) info = ms : badgeInfoMessages info
badgeInfoMessages :: BadgeInfo -> [ByteString]
badgeInfoMessages BadgeInfo {badgeType, badgeExpiry, badgeExtra} =
[encodeExpiry badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra]
[strEncode badgeExpiry, encodeUtf8 (textEncode badgeType), encodeUtf8 badgeExtra]
-- Payment verification (stub - always passes)
@@ -356,11 +364,11 @@ badgeToRow badge verified = localBadgeToRow $ (`PeerBadge` st) <$> badge
localBadgeToRow :: Maybe LocalBadge -> BadgeRow
localBadgeToRow (Just lb) = case lb of
OwnBadge (BadgeCredential idx (BadgeMasterKey mk) (BBSSignature sg) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st ->
(Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx)
(Nothing, Nothing, Just badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Just (Binary mk), Just (Binary sg), Just idx)
PeerBadge (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) st ->
(Just (Binary p), Just (Binary ph), badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx)
(Just (Binary p), Just (Binary ph), Just badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Just idx)
ShownBadge BadgeInfo {badgeType, badgeExpiry, badgeExtra} st ->
(Nothing, Nothing, badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing)
(Nothing, Nothing, Just badgeExpiry, Just (textEncode badgeType), verifiedField st, Just badgeExtra, Nothing, Nothing, Nothing)
where
verifiedField st = case st of
BSFailed -> Just (BI False)
@@ -369,9 +377,10 @@ localBadgeToRow (Just lb) = case lb of
localBadgeToRow Nothing = (Nothing, Nothing, Nothing, Nothing, Just (BI False), Nothing, Nothing, Nothing, Nothing)
rowToBadge :: UTCTime -> BadgeRow -> Maybe LocalBadge
rowToBadge now (p_, ph_, badgeExpiry, type_, verified_, extra_, mk_, sg_, idx_) = do
rowToBadge now (p_, ph_, expiry_, type_, verified_, extra_, mk_, sg_, idx_) = do
btText <- type_
bt <- textDecode btText
badgeExpiry <- expiry_
let info = BadgeInfo {badgeType = bt, badgeExpiry, badgeExtra = maybe "" id extra_}
-- NULL badge_verified means the key index was unknown when stored (Nothing)
st = mkBadgeStatus now (unBI <$> verified_) info
+4 -6
View File
@@ -28,7 +28,7 @@ bbsSecretLen = 32
data BadgeCommand
= Keygen
| MasterKey
| Sign Int BBSSecretKey BadgeMasterKey BadgeType (Maybe UTCTime)
| Sign Int BBSSecretKey BadgeMasterKey BadgeType UTCTime
runBadgeCommand :: [String] -> IO ()
runBadgeCommand args =
@@ -53,16 +53,14 @@ badgeCommandP =
<*> option (eitherReader secretR) (long "secret" <> metavar "ISSUER_SECRET" <> help "issuer secret from keygen (base64url)")
<*> option (eitherReader (strDecode . B.pack)) (long "master" <> metavar "MASTER" <> help "user master secret from master-key (base64url)")
<*> option (eitherReader badgeTypeR) (long "type" <> metavar "TYPE" <> help "badge type (supporter, legend, investor)")
<*> option (eitherReader expireR) (long "expire" <> metavar "lifetime|YYYY-MM-DD" <> help "expiry date, or 'lifetime'")
<*> option (eitherReader expireR) (long "expire" <> metavar "YYYY-MM-DD" <> help "expiry date")
secretR s = do
sk@(BBSSecretKey b) <- strDecode (B.pack s)
if B.length b == bbsSecretLen
then Right sk
else Left "bad issuer secret - use the 'secret' value from keygen"
badgeTypeR = maybe (Left "invalid badge type") Right . textDecode . T.pack
expireR = \case
"lifetime" -> Right Nothing
s -> maybe (Left "use 'lifetime' or YYYY-MM-DD") (Right . Just) $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s
expireR s = maybe (Left "use YYYY-MM-DD") Right $ parseTimeM True defaultTimeLocale "%Y-%m-%d" s
keygen :: IO ()
keygen =
@@ -78,7 +76,7 @@ genMasterKey = do
mk <- generateMasterKey drg
B.putStrLn $ strEncode mk
sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> Maybe UTCTime -> IO ()
sign :: Int -> BBSSecretKey -> BadgeMasterKey -> BadgeType -> UTCTime -> IO ()
sign keyIdx secretKey masterKey badgeType badgeExpiry = do
let req = VerifiedBadgeRequest (BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}} :: BadgeRequest)
issueBadge keyIdx secretKey req >>= \case
+5 -6
View File
@@ -56,7 +56,7 @@ import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
@@ -4986,7 +4986,7 @@ processChatCommand cxt nm = \case
chunkSize <- asks $ fileChunkSize . config
withFastStore' $ \db -> do
fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize
pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal}
pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal, fileExpires = Nothing}
prepareLocalItemsData ::
NonEmpty ComposedMessageReq ->
NonEmpty (Maybe (CIFile 'MDSnd)) ->
@@ -5139,8 +5139,9 @@ addUserBadge user cred@(BadgeCredential _ _ _ info) =
presentUserBadgeToContacts user'
presentUserBadgeToContacts :: User -> CM ()
presentUserBadgeToContacts user' = do
presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do
asks currentUser >>= atomically . (`writeTVar` Just user')
lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge)
cxt <- asks $ mkStoreCxt . config
contacts <- withFastStore' $ \db -> getUserContacts db cxt user'
withChatLock "presentUserBadge" $ forM_ contacts $ \ct ->
@@ -5207,14 +5208,12 @@ storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCr
-- sent - so a credential over any other master key also verifies
Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key"
Just True -> do
-- badge_issuances requires a period; a credential without an expiry has none to record
expiry <- maybe (throwCmdError "redeemed badge credential has no expiry") pure badgeExpiry
g <- asks random
now <- liftIO getCurrentTime
let badge = OwnBadge cred (mkBadgeStatus now (Just True) info)
-- TODO [badges] copy the statement's ledger entries, and retire a previously held badge
(user', newBadge) <- withStore' $ \db -> do
newBadge <- createCodeBadgePurchase db g user redemption cred expiry now
newBadge <- createCodeBadgePurchase db g user redemption cred badgeExpiry now
-- a replay must not put a superseded badge back, or tell every contact again
user' <- if newBadge then setUserBadge db user (Just badge) else pure user
pure (user', newBadge)
+12 -12
View File
@@ -441,12 +441,12 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr
fInv = xftpFileInvitation fileName fileSize dummyFileDescr
fsFilePath <- lift $ toFSFilePath filePath
let srcFile = CryptoFile fsFilePath cfArgs
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n)
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing
-- TODO CRSndFileStart event for XFTP
chSize <- asks $ fileChunkSize . config
ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize
let fileSource = Just $ CryptoFile filePath cfArgs
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP}
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing}
pure (fInv, ciFile, ft)
cryptoFileDigest :: CryptoFile -> CM FD.FileDigest
@@ -1417,7 +1417,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
resolveAuthor (Just gmId) = do
cxt <- chatStoreCxt
eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupMemberById db cxt user gmId)
getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText))
getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime))
getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do
expired <- fileExpired
if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired
@@ -1425,7 +1425,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
else do
rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId
pure $ invCompleteDescr ciFile rfd
getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText))
getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime))
getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do
expired <- fileExpired
if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired
@@ -1440,21 +1440,21 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
ttl <- asks $ rcvFilesTTL . agentConfig . config
cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime
pure $ chatItemTs cci < cutoffTs
invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText)
invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete}
invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime)
invCompleteDescr CIFile {fileName, fileSize, fileExpires} RcvFileDescr {fileDescrText, fileDescrComplete}
| fileDescrComplete =
let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
fInv = xftpFileInvitation fileName fileSize fInvDescr
in Just (fInv, fileDescrText)
in Just (fInv, fileDescrText, fileExpires)
| otherwise = Nothing
processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [(GrpMsgForward, VerifiedMsg 'Json)]
processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime) -> CM [(GrpMsgForward, VerifiedMsg 'Json)]
processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ =
if isNothing fInvDescr_ && not (msgContentHasText mc)
then pure []
else do
let CIMeta {itemTs, itemSharedMsgId, itemTimed, showGroupAsSender} = meta
quotedItemId_ = quoteItemId =<< quotedItem
fInv_ = fst <$> fInvDescr_
fInv_ = (\(fInv, _, _) -> fInv) <$> fInvDescr_
(mc', _, mentions') = updatedMentionNames mc formattedText mentions
mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions'
-- for channel messages default chat version range to membership range
@@ -1472,11 +1472,11 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
-- TODO [knocking] send history to other scopes too?
(chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing showGroupAsSender mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False
pure $ VMUnsigned ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent}
fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of
(Just fileDescrText, Just msgId) -> do
fileDescrEvents <- case (fInvDescr_, itemSharedMsgId) of
(Just (_, fileDescrText, fileExpires), Just msgId) -> do
partSize <- asks $ xftpDescrPartSize . config
let parts = splitFileDescr partSize fileDescrText
pure . L.toList $ L.map (XMsgFileDescr msgId) parts
pure . L.toList $ L.map (\fd -> XMsgFileDescr msgId fd fileExpires) parts
_ -> pure []
let fileDescrVMs = map (VMUnsigned . ChatMessage senderVRange Nothing) fileDescrEvents
pure $ map ((,) fwd) (contentVM : fileDescrVMs)
+25 -22
View File
@@ -41,6 +41,7 @@ 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.Clock.System (SystemTime (..), systemToUTCTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
@@ -75,7 +76,7 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.FileTransfer.Description (ValidFileDescription)
import qualified Simplex.FileTransfer.Description as FD
import Simplex.FileTransfer.Protocol (FilePartyI)
import Simplex.FileTransfer.Protocol (FilePartyI, GrantedStorageTime (..))
import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent
@@ -205,8 +206,9 @@ processAgentMsgSndFile _corrId aFileId msg = do
liftIO $ updateCIFileStatus db user fileId status
lookupChatItemByFileId db cxt user fileId
toView $ CEvtSndFileProgressXFTP user ci ft sndProgress sndTotal
SFDONE sndDescr rfds -> do
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr)
SFDONE sndDescr rfds granted -> do
let fileExpires = (\GSTExpires {epochSeconds} -> systemToUTCTime $ MkSystemTime epochSeconds 0) <$> granted
withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) fileExpires
ci <- withStore $ \db -> lookupChatItemByFileId db cxt user fileId
case ci of
Nothing -> do
@@ -232,7 +234,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
conn@Connection {connId} <- liftEither $ contactSendConn_ ct
sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId >>= \case
sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId fileExpires >>= \case
Just rs -> case L.last rs of
Right ([msgDeliveryId], _) ->
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
@@ -247,7 +249,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
extraRFDs = drop (length rfdsMemberFTs) rfds
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
forM_ (L.nonEmpty rfdsMemberFTs) $ \rfdsMemberFTs' ->
sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId
sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId fileExpires
ci' <- withStore $ \db -> do
liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
getChatItemByFileId db cxt user fileId
@@ -276,8 +278,8 @@ processAgentMsgSndFile _corrId aFileId msg = do
where
fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text
fileDescrText = safeDecodeUtf8 . strEncode
sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption))))
sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId = do
sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> Maybe UTCTime -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption))))
sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId fileExpires = do
lift . void . withStoreBatch' $ \db -> L.map (\(_, sft, rfdText) -> updateSndFTDescrXFTP db user sft rfdText) connsTransfersDescrs
partSize <- asks $ xftpDescrPartSize . config
let connsIdsEvts = connDescrEvents partSize
@@ -293,7 +295,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
where
splitText :: (Connection, SndFileTransfer, RcvFileDescrText) -> [(Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json))]
splitText (conn, _, rfdText) =
map (\fileDescr -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr}))) (L.toList $ splitFileDescr partSize rfdText)
map (\fileDescr -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr, fileExpires}))) (L.toList $ splitFileDescr partSize rfdText)
toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq
toMsgReq (conn, _) SndMessage {msgId, msgBody} =
(conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId]))
@@ -548,7 +550,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
let ct'' = ct' {activeConn = Just conn''} :: Contact
case event of
XMsgNew mc -> newContentMessage ct'' mc msg msgMeta
XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr
XMsgFileDescr sharedMsgId fileDescr fileExpires -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires
XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live
XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta
XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta
@@ -1033,7 +1035,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
where
MsgContainer {scope, asGroup} = mc
-- file description is always allowed, to allow sending files to support scope
XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr
XMsgFileDescr sharedMsgId fileDescr fileExpires -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr fileExpires
XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ ->
checkSendAsGroup asGroup_ $
memberCanSend (Just m'') msgScope $
@@ -1905,16 +1907,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
ChatConfig {autoAcceptFileSize = sz} <- asks config
when (sz > fileSize) $ receiveFileEvt' user ft False Nothing Nothing >>= toView
messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> CM ()
messageFileDescription Contact {contactId} sharedMsgId fileDescr = do
messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM ()
messageFileDescription Contact {contactId} sharedMsgId fileDescr fileExpires = do
(fileId, aci) <- withStore $ \db -> do
fileId <- getFileIdBySharedMsgId db userId contactId sharedMsgId
aci <- getChatItemByFileId db cxt user fileId
pure (fileId, aci)
processFDMessage fileId aci fileDescr
processFDMessage fileId aci fileDescr fileExpires
groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)
groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr = do
groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM (Maybe DeliveryTaskContext)
groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr fileExpires = do
(fileId, aci) <- withStore $ \db -> do
fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId
aci <- getChatItemByFileId db cxt user fileId
@@ -1925,17 +1927,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- in processFDMessage some paths are programmed as errors,
-- for example failure on not approved relays (CEFileNotApproved).
-- we catch error, so that even if processFDMessage fails, message can still be forwarded.
processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure ()
processFDMessage fileId aci fileDescr fileExpires `catchAllErrors` \_ -> pure ()
pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir)
| otherwise -> messageError "x.msg.file.descr: file/sender mismatch" $> Nothing
_ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing
processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> CM ()
processFDMessage fileId aci fileDescr = do
processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> Maybe UTCTime -> CM ()
processFDMessage fileId aci fileDescr fileExpires = do
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
unless (rcvFileCompleteOrCancelled ft) $ do
(rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do
rfd <- appendRcvFD db userId fileId fileDescr
forM_ fileExpires $ liftIO . setFileExpiration db user fileId
-- reading second time in the same transaction as appending description
-- to prevent race condition with accept
ft' <- getRcvFileTransfer db user fileId
@@ -1962,7 +1965,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
_ -> pure (Nothing, CIFSRcvInvitation, ft)
let RcvFileTransfer {cryptoArgs} = ft'
fileSource = (`CryptoFile` cryptoArgs) <$> filePath
pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol})
pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires = Nothing})
mkValidFileInvitation :: FileInvitation -> FileInvitation
mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = safeFileNameStr fileName}
@@ -2418,7 +2421,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
inline <- receiveInlineMode fInv' Nothing fileChunkSize
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' inline fileChunkSize
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol}
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing}
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
(ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
@@ -2433,7 +2436,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
inline <- receiveInlineMode fInv' Nothing 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}
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing}
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
(ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty
ci' <- blockedMemberCI gInfo m ci
@@ -3875,7 +3878,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
where
MsgContainer {scope} = mc
-- file description is always allowed, to allow sending files to support scope
XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr
XMsgFileDescr sharedMsgId fileDescr fileExpires -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires
XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ ->
void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_
XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs
+7 -1
View File
@@ -211,7 +211,7 @@ markdownP = mconcat <$> A.many' fragmentP
Just c -> case c of
' ' -> unmarked <$> A.takeWhile (== ' ')
'+' -> phoneP <|> wordP
'*' -> formattedP '*' Bold
'*' -> boldP <|> formattedP '*' Bold
'_' -> formattedP '_' Italic
'~' -> formattedP '~' StrikeThrough
'`' -> formattedP '`' Snippet
@@ -233,6 +233,12 @@ markdownP = mconcat <$> A.many' fragmentP
| T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ c `T.cons` s `T.snoc` c
| otherwise = markdown f s
boldP :: Parser Markdown
boldP = do
s <- A.string "**" *> A.takeTill (== '*') <* A.string "**"
if T.null s || T.head s == ' ' || T.last s == ' '
then fail "not bold"
else pure $ markdown Bold s
secretP :: Parser Markdown
secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#'))
secret :: (Text, Text, Text) -> Either String Markdown
+2 -1
View File
@@ -687,7 +687,8 @@ data CIFile (d :: MsgDirection) = CIFile
fileSize :: Integer,
fileSource :: Maybe CryptoFile, -- local file path with optional key and nonce
fileStatus :: CIFileStatus d,
fileProtocol :: FileProtocol
fileProtocol :: FileProtocol,
fileExpires :: Maybe UTCTime
}
deriving (Show)
+5 -5
View File
@@ -446,7 +446,7 @@ signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody =
data ChatMsgEvent (e :: MsgEncoding) where
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr, fileExpires :: Maybe UTCTime} -> ChatMsgEvent 'Json
XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json
XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, onlyHistory :: Bool} -> ChatMsgEvent 'Json
XMsgDeleted :: ChatMsgEvent 'Json
@@ -517,7 +517,7 @@ isForwardedGroupMsg ev = case ev of
XMsgNew mc -> case mc of
MsgContainer {file = Just FileInvitation {fileInline = Just _}} -> False
_ -> True
XMsgFileDescr _ _ -> True
XMsgFileDescr {} -> True
XMsgUpdate {} -> True
XMsgDel {} -> True
XMsgReact {} -> True
@@ -1239,7 +1239,7 @@ instance StrEncoding ACMEventTag where
toCMEventTag :: ChatMsgEvent e -> CMEventTag e
toCMEventTag msg = case msg of
XMsgNew _ -> XMsgNew_
XMsgFileDescr _ _ -> XMsgFileDescr_
XMsgFileDescr {} -> XMsgFileDescr_
XMsgUpdate {} -> XMsgUpdate_
XMsgDel {} -> XMsgDel_
XMsgDeleted -> XMsgDeleted_
@@ -1391,7 +1391,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
msg :: CMEventTag 'Json -> Either String (ChatMsgEvent 'Json)
msg = \case
XMsgNew_ -> XMsgNew <$> JT.parseEither parseJSON (J.Object params)
XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr"
XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" <*> opt "fileExpires"
XMsgUpdate_ -> do
msgId' <- p "msgId"
content <- p "content"
@@ -1482,7 +1482,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
XMsgNew mc -> case toJSON mc of
J.Object obj -> obj
_ -> JM.empty
XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr]
XMsgFileDescr msgId' fileDescr fileExpires -> o $ ("fileExpires" .=? fileExpires) ["msgId" .= msgId', "fileDescr" .= fileDescr]
XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content]
XMsgDel msgId' memberId scope onlyHistory -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) $ ("onlyHistory" .=? justTrue onlyHistory) ["msgId" .= msgId']
XMsgDeleted -> JM.empty
+1 -2
View File
@@ -92,8 +92,7 @@ deleteBadgeCodeRedemption db redemptionId =
-- | The purchase a redeemed code created, its issuance, and the profile's pointer to it.
-- False when the code was already redeemed here: the service replays the credential it issued,
-- and that must add no purchase and leave the shown badge alone. The expiry is passed in because
-- badge_issuances requires one and the caller has already resolved it.
-- and that must add no purchase and leave the shown badge alone.
createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool
createCodeBadgePurchase db g User {userId} redemption credential expiry now =
getCodeBadgePurchase db redemption >>= \case
+13 -4
View File
@@ -20,6 +20,7 @@ module Simplex.Chat.Store.Files
createSndFileTransferXFTP,
createSndFTDescrXFTP,
setSndFTPrivateSndDescr,
setFileExpiration,
getSndFTPrivateSndDescr,
updateSndFTDescrXFTP,
createExtraSndFTDescrs,
@@ -203,13 +204,21 @@ createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fi
"INSERT INTO snd_files (file_id, file_status, file_descr_id, group_member_id, connection_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)"
(fileId, fileStatus, fileDescrId, groupMemberId' <$> m, connId, currentTs, currentTs)
setSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> Text -> IO ()
setSndFTPrivateSndDescr db User {userId} fileId sfdText = do
setSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> Text -> Maybe UTCTime -> IO ()
setSndFTPrivateSndDescr db User {userId} fileId sfdText fileExpires = do
currentTs <- getCurrentTime
DB.execute
db
"UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?"
(sfdText, currentTs, userId, fileId)
"UPDATE files SET private_snd_file_descr = ?, file_expires_at = ?, updated_at = ? WHERE user_id = ? AND file_id = ?"
(sfdText, fileExpires, currentTs, userId, fileId)
setFileExpiration :: DB.Connection -> User -> FileTransferId -> UTCTime -> IO ()
setFileExpiration db User {userId} fileId fileExpires = do
currentTs <- getCurrentTime
DB.execute
db
"UPDATE files SET file_expires_at = ?, updated_at = ? WHERE user_id = ? AND file_id = ?"
(fileExpires, currentTs, userId, fileId)
getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text)
getSndFTPrivateSndDescr db User {userId} fileId =
+10 -10
View File
@@ -1093,7 +1093,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do
-- this function can be changed so it never fails, not only avoid failure on invalid json
toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal)
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) =
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) =
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
where
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
@@ -1113,7 +1113,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
let cfArgs = CFArgs <$> fileKey <*> fileNonce
fileSource = (`CryptoFile` cfArgs) <$> filePath
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
_ -> Nothing
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
cItem d chatDir ciStatus content file =
@@ -2276,7 +2276,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do
|]
(CISRcvRead, currentTs, userId, noteFolderId, CISRcvNew)
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol)
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol, Maybe UTCTime)
type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified)
@@ -2304,7 +2304,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir
-- this function can be changed so it never fails, not only avoid failure on invalid json
toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) =
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) :. quoteRow) =
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
where
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
@@ -2324,7 +2324,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
let cfArgs = CFArgs <$> fileKey <*> fileNonce
fileSource = (`CryptoFile` cfArgs) <$> filePath
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
_ -> Nothing
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
cItem d chatDir ciStatus content file =
@@ -2379,7 +2379,7 @@ toGroupChatItem
:. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt)
:. forwardedFromRow
:. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned)
:. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)
:. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)
)
:. (forwardedByMember, BI showGroupAsSender)
:. memberRow_
@@ -2414,7 +2414,7 @@ toGroupChatItem
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
let cfArgs = CFArgs <$> fileKey <*> fileNonce
fileSource = (`CryptoFile` cfArgs) <$> filePath
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
_ -> Nothing
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
cItem d chatDir ciStatus content file =
@@ -2705,7 +2705,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -3099,7 +3099,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
-- CIMeta forwardedByMember, showGroupAsSender
i.forwarded_by_group_member_id, i.show_group_as_sender,
-- GroupMember
@@ -3212,7 +3212,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ?
@@ -48,6 +48,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations
import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link
import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry
import Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
@@ -97,6 +98,7 @@ schemaMigrations =
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges)
]
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260828_file_expiry :: Text
m20260828_file_expiry =
[r|
ALTER TABLE files ADD COLUMN file_expires_at TIMESTAMPTZ;
|]
down_m20260828_file_expiry :: Text
down_m20260828_file_expiry =
[r|
ALTER TABLE files DROP COLUMN file_expires_at;
|]
@@ -923,7 +923,8 @@ CREATE TABLE test_chat_schema.files (
shared_msg_id bytea,
file_type text DEFAULT 'normal'::text NOT NULL,
roster_transfer_id bigint,
file_digest bytea
file_digest bytea,
file_expires_at timestamp with time zone
);
@@ -171,6 +171,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations
import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link
import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry
import Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
@@ -343,6 +344,7 @@ schemaMigrations =
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges)
]
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260828_file_expiry :: Query
m20260828_file_expiry =
[sql|
ALTER TABLE files ADD COLUMN file_expires_at TEXT;
|]
down_m20260828_file_expiry :: Query
down_m20260828_file_expiry =
[sql|
ALTER TABLE files DROP COLUMN file_expires_at;
|]
@@ -2,7 +2,7 @@ Query:
SELECT
f.snd_file_id, f.snd_file_entity_id, f.user_id, f.num_recipients, f.prefix_path,
c.snd_file_chunk_id, c.chunk_no, c.chunk_offset, c.chunk_size, c.digest,
r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries
r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, r.replica_expires_at
FROM snd_file_chunk_replicas r
JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id
JOIN snd_file_chunks c ON c.snd_file_chunk_id = r.snd_file_chunk_id
@@ -31,7 +31,7 @@ SEARCH sb USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
Query:
SELECT
r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries,
r.snd_file_chunk_replica_id, r.replica_id, r.replica_key, r.replica_status, r.delay, r.retries, r.replica_expires_at,
s.xftp_host, s.xftp_port, s.xftp_key_hash
FROM snd_file_chunk_replicas r
JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id
@@ -96,7 +96,7 @@ Plan:
SEARCH snd_file_chunks USING INDEX idx_snd_file_chunks_snd_file_id (snd_file_id=?)
Query:
SELECT snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, digest, prefix_path, key, nonce, status, deleted, redirect_size, redirect_digest
SELECT snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, digest, prefix_path, key, nonce, status, deleted, redirect_size, redirect_digest, storage_time
FROM snd_files
WHERE snd_file_id = ?
@@ -686,8 +686,8 @@ Plan:
Query:
INSERT INTO snd_file_chunk_replicas
(snd_file_chunk_id, replica_number, xftp_server_id, replica_id, replica_key, replica_status)
VALUES (?,?,?,?,?,?)
(snd_file_chunk_id, replica_number, xftp_server_id, replica_id, replica_key, replica_status, replica_expires_at)
VALUES (?,?,?,?,?,?,?)
Plan:
@@ -1182,7 +1182,7 @@ Plan:
Query: INSERT INTO snd_file_chunks (snd_file_id, chunk_no, chunk_offset, chunk_size, digest) VALUES (?,?,?,?,?)
Plan:
Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
Query: INSERT INTO snd_files (snd_file_entity_id, user_id, path, src_file_key, src_file_nonce, num_recipients, prefix_path, key, nonce, status, redirect_size, redirect_digest, storage_time) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
Query: INSERT INTO snd_message_bodies (agent_msg) VALUES (?) RETURNING snd_message_body_id
@@ -1370,7 +1370,7 @@ Query:
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at
FROM chat_items i
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ?
@@ -1388,7 +1388,7 @@ Query:
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
-- CIMeta forwardedByMember, showGroupAsSender
i.forwarded_by_group_member_id, i.show_group_as_sender,
-- GroupMember
@@ -1445,7 +1445,7 @@ Query:
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol,
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
-- DirectQuote
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM chat_items i
@@ -7754,11 +7754,15 @@ Query: UPDATE files SET file_crypto_key = ?, file_crypto_nonce = ?, updated_at =
Plan:
SEARCH files USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE files SET file_expires_at = ?, updated_at = ? WHERE user_id = ? AND file_id = ?
Plan:
SEARCH files USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?
Plan:
SEARCH files USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?
Query: UPDATE files SET private_snd_file_descr = ?, file_expires_at = ?, updated_at = ? WHERE user_id = ? AND file_id = ?
Plan:
SEARCH files USING INTEGER PRIMARY KEY (rowid=?)
@@ -302,7 +302,8 @@ CREATE TABLE files(
shared_msg_id BLOB,
file_type TEXT NOT NULL DEFAULT 'normal',
roster_transfer_id INTEGER,
file_digest BLOB
file_digest BLOB,
file_expires_at TEXT
) STRICT;
CREATE TABLE snd_files(
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
+1 -1
View File
@@ -1829,7 +1829,7 @@ viewContactBadge = maybe [] $ \lb ->
BSExpiredOld -> "expired (old)"
BSFailed -> "verification failed"
BSUnknownKey -> "unknown key"
expiry = maybe "no expiry" (("expires " <>) . T.pack . formatTime defaultTimeLocale "%Y-%m-%d") badgeExpiry
expiry = "expires " <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" badgeExpiry)
in [plain (textEncode badgeType <> " badge - " <> st), plain expiry]
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]