Merge branch 'master' into ep/parallel-tests

This commit is contained in:
Evgeny @ SimpleX Chat
2026-10-05 12:18:58 +00:00
169 changed files with 5496 additions and 664 deletions
+3
View File
@@ -29,6 +29,7 @@ import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import Data.Time.Clock (getCurrentTime, nominalDay)
import Simplex.Chat.Badges (badgeServerCredential, defaultFileSizeLimits)
import Simplex.Chat.Call (supportedCallVRange)
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands
import Simplex.Chat.Operators
@@ -68,6 +69,7 @@ defaultChatConfig =
tbqSize = 1024
},
chatVRange = supportedChatVRange,
callVRange = supportedCallVRange,
badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys,
badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM",
badgeCurrentTime = getCurrentTime,
@@ -123,6 +125,7 @@ defaultChatConfig =
cleanupManagerInterval = 30 * 60, -- 30 minutes
cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds
ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes
callInvitationTTL = 180, -- 3 minutes, the apps stop ringing for older invitations
highlyAvailable = False,
deliveryWorkerDelay = 0,
deliveryBucketSize = 10000,
+4
View File
@@ -124,11 +124,15 @@ deleteStorage = do
fs <- lift storageFiles
liftIO $ closeDBStore `withStores` fs
remove `withDBs` fs
removeExported `withDBs` fs
removeBackup `withDBs` fs
mapM_ removeDir $ filesPath fs
mapM_ removeDir $ assetsPath fs
mapM_ removeDir =<< chatReadVar tempDirectory
where
remove f = whenM (doesFileExist f) $ removeFile f
removeExported f = remove $ f <> ".exported"
removeBackup f = remove $ f <> ".bak"
removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d
data StorageFiles = StorageFiles
+57 -3
View File
@@ -1,11 +1,13 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
@@ -21,6 +23,7 @@ import Data.ByteString.Char8 (ByteString)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word (Word16)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types (Contact, ContactId, User)
import Simplex.Messaging.Agent.Store.DB (Binary (..), fromTextField_)
@@ -28,6 +31,8 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON)
import Simplex.Messaging.Util (decodeJSON, encodeJSON)
import Simplex.Messaging.Version
import Simplex.Messaging.Version.Internal
data Call = Call
{ contactId :: ContactId,
@@ -39,6 +44,52 @@ data Call = Call
}
deriving (Show)
-- Call version history:
-- 1 - DH secret is used as media key (also assumed when call messages have no version)
-- 2 - media key is derived from DH secret with HKDF (2026-10-01)
data CallVersion
instance VersionScope CallVersion
type VersionCall = Version CallVersion
type VersionRangeCall = VersionRange CallVersion
pattern VersionCall :: Word16 -> VersionCall
pattern VersionCall v = Version v
initialCallVersion :: VersionCall
initialCallVersion = VersionCall 1
callMediaKdfVersion :: VersionCall
callMediaKdfVersion = VersionCall 2
-- This should not be used directly in code, instead use `callVRange` from ChatConfig
currentCallVersion :: VersionCall
currentCallVersion = VersionCall 2
supportedCallVRange :: VersionRangeCall
supportedCallVRange = mkVersionRange initialCallVersion currentCallVersion
callInitialVRange :: VersionRangeCall
callInitialVRange = versionToRange initialCallVersion
newtype CallVersionRange = CallVersionRange {fromCallVRange :: VersionRangeCall}
deriving (Eq, Show)
deriving (FromJSON, ToJSON) via (StrJSON "CallVersionRange" VersionRangeCall)
callMediaKey :: VersionCall -> CallId -> C.PublicKeyX25519 -> C.PrivateKeyX25519 -> C.Key
callMediaKey v (CallId salt) peerPubKey privKey
| v >= callMediaKdfVersion = C.Key $ C.hkdf salt dhSecret "SimpleXCallMediaKey" callMediaKeySize
| otherwise = C.Key dhSecret
where
dhSecret = C.dhBytes' $ C.dh' peerPubKey privKey
-- AES-256-GCM key used by the apps for frame encryption
callMediaKeySize :: Int
callMediaKeySize = 32
isRcvInvitation :: Call -> Bool
isRcvInvitation Call {callState} = case callState of
CallInvitationReceived {} -> True
@@ -68,7 +119,8 @@ data CallState
| CallInvitationReceived
{ peerCallType :: CallType,
localDhPubKey :: Maybe C.PublicKeyX25519,
sharedKey :: Maybe C.Key
sharedKey :: Maybe C.Key,
callVersion :: Maybe VersionCall
}
| CallOfferSent
{ localCallType :: CallType,
@@ -134,7 +186,8 @@ encryptedCall CallType {capabilities = CallCapabilities {encryption}} = encrypti
-- | * Types for chat protocol
data CallInvitation = CallInvitation
{ callType :: CallType,
callDhPubKey :: Maybe C.PublicKeyX25519
callDhPubKey :: Maybe C.PublicKeyX25519,
callVRange :: Maybe CallVersionRange
}
deriving (Eq, Show)
@@ -149,7 +202,8 @@ data CallCapabilities = CallCapabilities
data CallOffer = CallOffer
{ callType :: CallType,
rtcSession :: WebRTCSession,
callDhPubKey :: Maybe C.PublicKeyX25519
callDhPubKey :: Maybe C.PublicKeyX25519,
callVersion :: Maybe VersionCall
}
deriving (Eq, Show)
+3 -1
View File
@@ -144,6 +144,7 @@ coreVersionInfo simplexmqCommit =
data ChatConfig = ChatConfig
{ agentConfig :: AgentConfig,
chatVRange :: VersionRangeChat,
callVRange :: VersionRangeCall,
-- issuer public keys by index: credentials and proofs name the key that signed them, for rotation
badgePublicKeys :: Map Int BBSPublicKey,
-- Nothing until the badge service is deployed
@@ -174,6 +175,7 @@ data ChatConfig = ChatConfig
cleanupManagerInterval :: NominalDiffTime,
cleanupManagerStepDelay :: Int64,
ciExpirationInterval :: Int64, -- microseconds
callInvitationTTL :: NominalDiffTime,
deliveryWorkerDelay :: Int64, -- microseconds
deliveryBucketSize :: Int,
webPreviewConfig :: Maybe WebPreviewConfig,
@@ -446,7 +448,7 @@ data ChatCommand
| APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile {userId :: UserId, profile :: Profile}
| APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain}
| APISetUserDomain {userId :: UserId, simplexDomain :: Maybe (StrJSON "SimplexDomain" SimplexDomain)}
| APISetContactPrefs {contactId :: ContactId, preferences :: Preferences}
| APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias}
| APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias}
+9 -2
View File
@@ -13,12 +13,15 @@ module Simplex.Chat.Core
)
where
import Control.Concurrent (forkIO)
import Control.Exception (mask, onException, throwTo)
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import qualified Data.ByteString.Char8 as B
import Data.List (find)
import Data.Maybe (isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
@@ -94,8 +97,12 @@ runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatR
a1 <- runReaderT (startChatController True True False) cc
when (chatRelay && not testView) $ askCreateRelayAddress cc u chatRelayServer headless
forM_ (postStartHook chatHooks) ($ cc)
a2 <- async $ chat u cc
waitEither_ a1 a2
-- throwTo waits while the callback is masked, so an outside interrupt cancels it from a forked thread.
-- A /_stop sent from the callback ends a1 first, which leaves the callback running.
mask $ \restore -> do
a2 <- asyncWithUnmask $ \unmask -> unmask (chat u cc)
let cancelCallback = poll a1 >>= \r -> when (isNothing r) $ void $ forkIO $ throwTo (asyncThreadId a2) AsyncCancelled
restore (waitEither_ a1 a2) `onException` cancelCallback
sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse)
sendChatCmdStr cc s = runReaderT (execChatCommand CSLocal (encodeUtf8 $ T.pack s) 0) cc
+15 -8
View File
@@ -351,7 +351,9 @@ startReceiveUserFiles user = do
restoreCalls :: CM' ()
restoreCalls = do
savedCalls <- fromRight [] <$> runExceptT (withFastStore' getCalls)
ttl <- asks (callInvitationTTL . config)
cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime
savedCalls <- fromRight [] <$> runExceptT (withFastStore' $ \db -> expireCalls db cutoffTs >> getCalls db)
let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls
calls <- asks currentCalls
atomically $ writeTVar calls callsMap
@@ -1509,7 +1511,8 @@ processChatCommand cxt nm = \case
callId <- atomically $ CallId <$> C.randomBytes 16 g
callUUID <- UUID.toText <$> liftIO V4.nextRandom
dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
ChatConfig {callVRange = callVR} <- asks config
let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair, callVRange = Just $ CallVersionRange callVR}
callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
(msg, _) <- sendDirectContactMessage user ct (XCallInv callId invitation)
ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0)
@@ -1537,9 +1540,9 @@ processChatCommand cxt nm = \case
APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} ->
-- party accepting call
withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of
CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do
CallInvitationReceived {peerCallType, localDhPubKey, sharedKey, callVersion} -> do
let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing
offer = CallOffer {callType, rtcSession, callDhPubKey}
offer = CallOffer {callType, rtcSession, callDhPubKey, callVersion}
callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey}
aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0
(SndMessage {msgId}, _) <- sendDirectContactMessage user ct (XCallOffer callId offer)
@@ -1594,7 +1597,8 @@ processChatCommand cxt nm = \case
withCurrentCall contactId $ \user ct call ->
updateCallItemStatus user ct call receivedStatus Nothing $> Just call
APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile)
APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} ->
APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do
let domain_ = unStrJSON <$> strDomain_
if (claimDomain <$> contactDomain) == domain_
then pure $ CRUserProfileNoChange user
else do
@@ -2866,7 +2870,8 @@ processChatCommand cxt nm = \case
Nothing -> throwChatError $ CEContactNotActive ct
APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do
(g@(GIK gInfo _), m) <- withFastStore $ \db -> (,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupMemberById db cxt user gmId
assertUserGroupRole gInfo $ max GRModerator role
-- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member
assertUserGroupRole gInfo $ roleRequiredToChange GRMember role
case memberStatus m of
GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve
let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo
@@ -2945,6 +2950,8 @@ processChatCommand cxt nm = \case
throwCmdError "can't change role of multiple members when admins selected, or new role is admin"
when anyPending $ throwCmdError "can't change role of members pending approval"
when (anyRelay || newRole == GRRelay) $ throwCmdError "relay role can't be changed"
-- TODO [multi-owner] allow once owners are added via link data - until then promoted owner would lack link authority
when (useRelays' gInfo && newRole == GROwner) $ throwCmdError "owner role can't be assigned in channels"
-- TODO allow moderators (needs UI) - relay is rejected above (anyRelay), so drop the GRAdmin floor:
-- TODO assertUserGroupRole gInfo (roleRequiredToChange maxRole newRole)
assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole])
@@ -3980,7 +3987,7 @@ processChatCommand cxt nm = \case
dm <- case gInfo_ of
Just (Just gInfo@(GIK g gks))
| useRelays' g -> case relayMemberId_ of
Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend
Just relayMemberId -> encodeXMemberConnInfo pqSup gInfo relayMemberId profileToSend
Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId"
| otherwise -> encodeConnInfoPQ pqSup $ XContact profileToSend (Just $ groupMemberKey gks) (Just xContactId) welcomeSharedMsgId msg_
_ ->
@@ -6123,7 +6130,7 @@ chatCommandP =
"/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP),
"/_call get" $> APIGetCallInvitations,
"/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP),
"/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)),
"/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> (StrJSON <$> strP))),
"/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
+35 -18
View File
@@ -840,6 +840,16 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete}
rcvSize = max (toInteger encSize) redirectSize
-- 10 MB margin: encryption and chunk-size rounding make the transfer larger than the advertised size
maxRcvSize = min expectedFileSize (toInteger FD.maxFileSizeHard) + toInteger (FD.mb 10 :: Int64)
-- TODO re-enable redirects with relay checks
when (isJust redirect) $ do
cxt <- chatStoreCxt
aci_ <- withStore $ \db -> do
liftIO $ updateFileCancelled db user fileId (CIFSRcvError $ FileErrOther "redirect not allowed")
lookupChatItemByFileId db cxt user fileId
forM_ aci_ $ \aci -> do
cleanupACIFile aci
toView $ CEvtChatItemUpdated user aci
throwChatError $ CEInvalidFileDescription "redirect not allowed"
when (rcvSize > maxRcvSize) $ throwChatError $ CEFileRcvChunk "declared file size exceeds the file invitation size"
if userApprovedRelays
then receive' rd True
@@ -2438,6 +2448,19 @@ batchSendConnMessagesB mode _user conn msgFlags msgs_ = do
batchSndMessagesJSON :: BatchMode -> NonEmpty (Either ChatError SndMessage) -> [Either ChatError MsgBatch]
batchSndMessagesJSON mode = batchMessages mode maxEncodedMsgLength . L.toList
compressToLimit :: MonadError ChatError m => Int -> MsgBody -> m MsgBody
compressToLimit maxLen s
| B.length s <= maxLen = pure s
| B.length s' <= maxLen = pure s'
| otherwise = throwError $ ChatError $ CEException "large compressed body"
where
s' = compressedBatchMsgBody_ s
compressConnInfo :: PQSupport -> MsgBody -> CM MsgBody
compressConnInfo pqSup = compressToLimit $ case pqSup of
PQSupportOn -> maxEncodedInfoLengthPQ
PQSupportOff -> maxEncodedInfoLength
encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString
encodeConnInfo = encodeConnInfoPQ PQSupportOff
@@ -2446,32 +2469,27 @@ encodeConnInfoPQ pqSup chatMsgEvent = do
cxt <- chatStoreCxt
let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent}
case encodeChatMessage maxEncodedInfoLength info of
ECMEncoded connInfo -> case pqSup of
PQSupportOn | B.length connInfo > maxCompressedInfoLength -> do
let connInfo' = compressedBatchMsgBody_ connInfo
when (B.length connInfo' > maxCompressedInfoLength) $ throwChatError $ CEException "large compressed info"
pure connInfo'
_ -> pure connInfo
ECMEncoded connInfo -> compressConnInfo pqSup connInfo
ECMLarge -> throwChatError $ CEException "large info"
-- conn-info wrapped as a signed element, so the receiver can verify the signature over the body
encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString
encodeSignedConnInfo signing chatMsgEvent = do
encodeSignedConnInfo :: MsgEncodingI e => PQSupport -> MsgSigning -> ChatMsgEvent e -> CM ByteString
encodeSignedConnInfo pqSup signing chatMsgEvent = do
vr <- chatVersionRange
let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent}
case encodeChatMessage maxEncodedInfoLength info of
ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body
ECMEncoded body -> compressConnInfo pqSup $ encodeBatchElement (Just $ signChatMsgBody signing body) body
ECMLarge -> throwChatError $ CEException "large signed info"
-- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries
-- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay
encodeXMemberConnInfo :: GroupInfoKeys -> MemberId -> Profile -> CM ByteString
encodeXMemberConnInfo (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend =
encodeXMemberConnInfo :: PQSupport -> GroupInfoKeys -> MemberId -> Profile -> CM ByteString
encodeXMemberConnInfo pqSup (GIK gInfo@GroupInfo {membership = GroupMember {memberId}} gks) relayMemberId profileToSend =
let memberPrivKey' = memberPrivKey gks
xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey') (Just relayMemberId)
bindingData = groupBindingData gInfo memberId (C.publicKey memberPrivKey')
signing = MsgSigning CBGroup bindingData KRMember memberPrivKey'
in encodeSignedConnInfo signing xMemberEvt
in encodeSignedConnInfo pqSup signing xMemberEvt
deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption)
deliverMessage conn cmEventTag msgBody msgId = do
@@ -2495,21 +2513,20 @@ deliverMessages msgs = deliverMessagesB $ L.map Right msgs
deliverMessagesB :: NonEmpty (Either ChatError ChatMsgReq) -> CM (NonEmpty (Either ChatError ([Int64], PQEncryption)))
deliverMessagesB msgReqs = do
msgReqs' <- if any connSupportsPQ msgReqs then liftIO compressBodies else pure msgReqs
msgReqs' <- liftIO compressBodies
sent <- L.zipWith prepareBatch msgReqs' <$> withAgent (`sendMessagesB` snd (mapAccumL toAgent Nothing msgReqs'))
lift . void $ withStoreBatch' $ \db -> map (updatePQSndEnabled db) (rights . L.toList $ sent)
lift . withStoreBatch $ \db -> L.map (bindRight $ createDelivery db) sent
where
-- group sends share bodies between connections via VRRef, so the smallest limit applies to the batch
maxLen = if any connSupportsPQ msgReqs then maxEncodedMsgLengthPQ else maxEncodedMsgLength
connSupportsPQ = \case
Right (Connection {pqSupport = PQSupportOn}, _, _) -> True
_ -> False
compressBodies =
forME msgReqs $ \(conn, msgFlags, (mbr, msgIds)) -> runExceptT $ do
mbr' <- case mbr of
VRValue i msgBody | B.length msgBody > maxCompressedMsgLength -> do
let msgBody' = compressedBatchMsgBody_ msgBody
when (B.length msgBody' > maxCompressedMsgLength) $ throwError $ ChatError $ CEException "large compressed message"
pure $ VRValue i msgBody'
VRValue i msgBody -> VRValue i <$> compressToLimit maxLen msgBody
v -> pure v
pure (conn, msgFlags, (mbr', msgIds))
toAgent prev = \case
@@ -3045,7 +3062,7 @@ allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = d
Just gInfo@(GIK g _) | useRelays' g || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg
_ -> Nothing
dm <- case signing_ of
Just signing -> encodeSignedConnInfo signing msg
Just signing -> encodeSignedConnInfo pqSupport signing msg
Nothing -> encodeConnInfoPQ pqSupport msg
allowAgentConnectionInfo user conn confId dm
+28 -15
View File
@@ -1250,7 +1250,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup
let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile
dm <- encodeXMemberConnInfo g relayMemberId profileToSend
dm <- encodeXMemberConnInfo pqSup g relayMemberId profileToSend
subMode <- chatReadVar subscriptionMode
(cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq
joinAgentConnectionAsync cmdId True connId' True cReq dm subMode
@@ -2829,7 +2829,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
xGrpLinkAcpt :: GroupInfoKeys -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM ()
xGrpLinkAcpt g@(GIK gInfo@GroupInfo {membership} _) m acceptance role memberId msg brokerTs
| memberRole' m < GRModerator || memberRole' m < role =
-- same rule as role change (moderators grant up to member); pending member's role is a stand-in, so treat it as member
| memberRole' m < roleRequiredToChange GRMember role =
messageError "x.grp.link.acpt with insufficient member permissions"
| sameMemberId memberId membership = processUserAccepted
| otherwise =
@@ -3012,15 +3013,23 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
-- to party accepting call
xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> CM ()
xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey} msg@RcvMessage {sharedMsgId_} msgMeta = do
xCallInv ct@Contact {contactId} callId CallInvitation {callType, callDhPubKey, callVRange = peerCallVRange} msg@RcvMessage {sharedMsgId_} msgMeta = do
if featureAllowed SCFCalls forContact ct
then do
ChatConfig {callVRange = callVR} <- asks config
case callVR `compatibleVersion` maybe callInitialVRange fromCallVRange peerCallVRange of
Just (Compatible callVersion) -> createCallInvitation callVersion
Nothing -> messageError "x.call.inv: incompatible call version"
else featureRejected CFCalls
where
brokerTs = metaBrokerTs msgMeta
createCallInvitation callVersion = do
g <- asks random
dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing
(ci, cInfo) <- saveCallItem CISCallPending
callUUID <- UUID.toText <$> liftIO V4.nextRandom
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair))
callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey}
let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> (snd <$> dhKeyPair)
callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey, callVersion = Just callVersion}
call' = Call {contactId, callId, callUUID, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci}
calls <- asks currentCalls
-- theoretically, the new call invitation for the current contact can mark the in-progress call as ended
@@ -3031,9 +3040,6 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing
toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci}
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
else featureRejected CFCalls
where
brokerTs = metaBrokerTs msgMeta
saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0)
featureRejected f = do
let content = ciContentNoParse $ CIRcvChatFeatureRejected f
@@ -3042,18 +3048,25 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
-- to party initiating call
xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM ()
xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey} msg = do
xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey, callVersion = peerCallVersion} msg = do
ChatConfig {callVRange = callVR} <- asks config
msgCurrentCall ct callId "x.call.offer" msg $
\call -> case callState call of
CallInvitationSent {localCallType, localDhPrivKey} -> do
let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey)
callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey}
askConfirmation = encryptedCall localCallType && not (encryptedCall callType)
toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation}
pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0)
CallInvitationSent {localCallType, localDhPrivKey}
| callVersion `isCompatible` callVR -> do
let sharedKey = callMediaKey callVersion callId <$> callDhPubKey <*> localDhPrivKey
callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey}
askConfirmation = encryptedCall localCallType && isNothing sharedKey
toView CEvtCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation}
pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0)
| otherwise -> do
messageError "x.call.offer: unsupported call version"
pure (Just call, Nothing)
_ -> do
msgCallStateError "x.call.offer" call
pure (Just call, Nothing)
where
callVersion = fromMaybe initialCallVersion peerCallVersion
-- to party accepting call
xCallAnswer :: Contact -> CallId -> CallAnswer -> RcvMessage -> CM ()
+5 -5
View File
@@ -518,11 +518,11 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill
refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\''
commandTextP :: Parser (Text, Text)
commandTextP = do
(cmd, punct) <- displayNameTextP_
case T.words cmd of
(keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct)
_ -> fail "invalid command keyword"
commandTextP = commandText <$> displayNameTextP_
where
commandText (cmd, punct)
| T.null cmd = (punct, "")
| otherwise = (cmd, punct)
splitPunctuation :: Text -> (Text, Text)
splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s)
+16 -9
View File
@@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version)
-- This indirection is needed for backward/forward compatibility testing.
-- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code.
currentChatVersion :: VersionChat
currentChatVersion = VersionChat 20
currentChatVersion = VersionChat 21
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
supportedChatVRange :: VersionRangeChat
@@ -140,6 +140,9 @@ groupRosterVersion = VersionChat 19
groupMemberKeyVersion :: VersionChat
groupMemberKeyVersion = VersionChat 20
anyTextCommandsVersion :: VersionChat
anyTextCommandsVersion = VersionChat 21
data ConnectionEntity
= RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact}
| RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember}
@@ -922,8 +925,8 @@ maxEncodedMsgLength :: Int
maxEncodedMsgLength = 15602
-- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent
maxCompressedMsgLength :: Int
maxCompressedMsgLength = 13380
maxEncodedMsgLengthPQ :: Int
maxEncodedMsgLengthPQ = 13380
maxDecompressedMsgLength :: Int
maxDecompressedMsgLength = 65536
@@ -932,6 +935,9 @@ maxDecompressedMsgLength = 65536
maxBatchElementCount :: Int
maxBatchElementCount = 255
maxFwdDepth :: Int
maxFwdDepth = 1
-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
-- promotion cap over the promoted (member/moderator/admin) set.
maxGroupRosterSize :: Int
@@ -959,8 +965,8 @@ rosterBlobP = do
maxEncodedInfoLength :: Int
maxEncodedInfoLength = 14694
maxCompressedInfoLength :: Int
maxCompressedInfoLength = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent
maxEncodedInfoLengthPQ :: Int
maxEncodedInfoLengthPQ = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent
data EncodedChatMessage = ECMEncoded ByteString | ECMLarge
@@ -1384,8 +1390,8 @@ appBinaryToCM AppMessageBinary {msgId, tag, body} = do
msg = \case
BFileChunk_ -> BFileChunk <$> (SharedMsgId <$> smpP) <*> (unIFC <$> smpP)
appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json)
appJsonToCM AppMessageJson {v, msgId, event, params} = do
appJsonToCM :: Int -> AppMessageJson -> Either String (ChatMessage 'Json)
appJsonToCM fwdDepth AppMessageJson {v, msgId, event, params} = do
eventTag <- strDecode $ encodeUtf8 event
chatMsgEvent <- msg eventTag
pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent}
@@ -1460,11 +1466,12 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error"
XGrpRosterRequest_ -> XGrpRosterRequest <$> opt "version"
XGrpMsgForward_ -> do
when (fwdDepth >= maxFwdDepth) $ Left "forward depth exceeds limit"
fwdSender <- opt "memberId" >>= \case
Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName"
Nothing -> pure FwdChannel
fwdBrokerTs <- p "msgTs"
XGrpMsgForward (GrpMsgForward {fwdSender, fwdBrokerTs}) <$> p "msg"
XGrpMsgForward (GrpMsgForward {fwdSender, fwdBrokerTs}) <$> (appJsonToCM (fwdDepth + 1) =<< p "msg")
XInfoProbe_ -> XInfoProbe <$> p "probe"
XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash"
XInfoProbeOk_ -> XInfoProbeOk <$> p "probe"
@@ -1577,7 +1584,7 @@ instance ToJSON (ChatMessage 'Json) where
toJSON = (\(AMJson msg) -> toJSON msg) . chatToAppMessage
instance FromJSON (ChatMessage 'Json) where
parseJSON v = appJsonToCM <$?> parseJSON v
parseJSON v = appJsonToCM 0 <$?> parseJSON v
instance FromField (ChatMessage 'Json) where
fromField = blobFieldDecoder J.eitherDecodeStrict'
+3 -3
View File
@@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files"
-- when acting as host
minRemoteCtrlVersion :: AppVersion
minRemoteCtrlVersion = AppVersion [7, 1, 0, 5]
minRemoteCtrlVersion = AppVersion [7, 1, 0, 9]
-- when acting as controller
minRemoteHostVersion :: AppVersion
minRemoteHostVersion = AppVersion [7, 1, 0, 5]
minRemoteHostVersion = AppVersion [7, 1, 0, 9]
currentAppVersion :: AppVersion
currentAppVersion = AppVersion SC.version
@@ -517,7 +517,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo
where
parseRequest :: ExceptT RemoteProtocolError IO (C.SbKeyNonce, GetChunk, RemoteCommand)
parseRequest = do
(rfKN, header, getNext) <- parseDecryptHTTP2Body encryption request reqBody
(rfKN, header, getNext) <- parseDecryptHTTP2Body maxCommandBodySize encryption request reqBody
(rfKN,getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecodeStrict header)
replyError = reply . RRChatResponse . RRError
processCommand :: User -> C.SbKeyNonce -> GetChunk -> RemoteCommand -> CM ()
+13 -9
View File
@@ -40,7 +40,8 @@ import Simplex.Chat.Controller
import Simplex.Chat.Remote.Transport
import Simplex.Chat.Remote.Types
import Simplex.Chat.Types (BoolDef (..))
import Simplex.FileTransfer.Description (FileDigest (..))
import Simplex.FileTransfer.Description (FileDigest (..), mb)
import Simplex.Messaging.Compression (limitDecompress')
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import Simplex.Messaging.Crypto.Lazy (LazyByteString)
@@ -180,7 +181,7 @@ sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_
encFile_ <- mapM (prepareEncryptedFile sfKN) file_
let req = httpRequest encFile_ encCmd
HTTP2Response {response, respBody} <- liftError' (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing
(rfKN, header, getNext) <- parseDecryptHTTP2Body encryption response respBody
(rfKN, header, getNext) <- parseDecryptHTTP2Body maxResponseBodySize encryption response respBody
rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecodeStrict header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding
pure (rfKN, getNext, rr)
where
@@ -273,9 +274,15 @@ encryptEncodeHTTP2Body corrId cmdKN RemoteCrypto {sessionCode, signatures, compr
sign :: C.PrivateKeyEd25519 -> CH.Context SHA512 -> ByteString
sign k = C.signatureBytes . C.sign' k . BA.convert . CH.hashFinalize
maxCommandBodySize :: Int
maxCommandBodySize = mb 64
maxResponseBodySize :: Int
maxResponseBodySize = mb 512
-- | Parse and decrypt HTTP2 request/response
parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, ByteString, Int -> IO ByteString)
parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr HTTP2Body {bodyBuffer} = do
parseDecryptHTTP2Body :: HTTP2BodyChunk a => Int -> RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, ByteString, Int -> IO ByteString)
parseDecryptHTTP2Body maxSize rc@RemoteCrypto {sessionCode, signatures, compression} hr HTTP2Body {bodyBuffer} = do
(corrId, ct) <- getBody
(cmdKN, rfKN) <- ExceptT $ atomically $ getRemoteRcvKeys rc corrId
s <- liftError PRERemoteControl $ RC.rcDecryptBody cmdKN ct
@@ -287,7 +294,7 @@ parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr
corrIdStr <- liftIO $ getNext 4
ctLenStr <- liftIO $ getNext 4
let ctLen = decodeWord32 ctLenStr
when (ctLen > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize
when (ctLen > fromIntegral maxSize) $ throwError RPEInvalidSize
chunks <- liftIO $ getLazy $ fromIntegral ctLen
let hc = CH.hashUpdates (CH.hashInit @SHA512) [corrIdStr, ctLenStr]
hc' = CH.hashUpdates hc chunks
@@ -330,8 +337,5 @@ parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures, compression} hr
getNext sz = getBuffered bodyBuffer sz Nothing $ getBodyChunk hr
decompress :: LazyByteString -> ExceptT RemoteProtocolError IO ByteString
decompress s
| compression = case Z1.decompress $ LB.toStrict s of
Z1.Error e -> throwError $ RPEInvalidBody e
Z1.Skip -> pure B.empty
Z1.Decompress s' -> pure s'
| compression = liftEitherWith RPEInvalidBody $ limitDecompress' maxSize $ LB.toStrict s
| otherwise = pure $ LB.toStrict s
+3
View File
@@ -18,6 +18,7 @@ import Control.Monad (when)
import qualified Data.Aeson.TH as J
import Data.ByteString (ByteString)
import Data.Int (Int64)
import qualified Data.Map.Strict as M
import Data.Text (Text)
import Data.Word (Word16, Word32)
import Simplex.Chat.Remote.AppVersion
@@ -73,8 +74,10 @@ getRemoteRcvKeys RemoteCrypto {rcvCounter, chainKeys = TSbChainKeys {rcvKey}, sk
| otherwise = do -- prevCorrId < corrId
writeTVar rcvCounter corrId
skipKeys (prevCorrId + 1)
modifyTVar' skippedKeys $ \m -> M.drop (M.size m - maxSkippedKeys) m
Right <$> getKeys
maxSkip = 256
maxSkippedKeys = 1024
getKeys = (,) <$> stateTVar rcvKey C.sbcHkdf <*> stateTVar rcvKey C.sbcHkdf
skipKeys !cId =
when (cId < corrId) $ do
+23 -1
View File
@@ -77,6 +77,7 @@ module Simplex.Chat.Store.Profiles
createCall,
deleteCalls,
getCalls,
expireCalls,
createCommand,
setCommandConnId,
deleteCommand,
@@ -103,6 +104,7 @@ import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Simplex.Chat.Badges (LocalBadge, localBadgeToRow)
import Simplex.Chat.Call
import Simplex.Chat.Messages
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct
@@ -126,7 +128,7 @@ import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Transport.Client (TransportHost)
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
@@ -1084,6 +1086,26 @@ getCalls db =
toCall :: (ContactId, CallId, Text, ChatItemId, CallState, UTCTime) -> Call
toCall (contactId, callId, callUUID, chatItemId, callState, callTs) = Call {contactId, callId, callUUID, chatItemId, callState, callTs}
-- only received call invitations are stored, so their chat items are pending and become missed
expireCalls :: DB.Connection -> UTCTime -> IO ()
expireCalls db cutoffTs = do
itemIds :: [ChatItemId] <- map fromOnly <$> DB.query db "DELETE FROM calls WHERE call_ts < ? RETURNING chat_item_id" (Only cutoffTs)
currentTs <- getCurrentTime
let content = CIRcvCall CISCallMissed 0
contentText = ciContentToText content
unless (null itemIds) $
#if defined(dbPostgres)
DB.execute
db
"UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id IN ?"
(content, contentText, currentTs, In itemIds)
#else
DB.executeMany
db
"UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ?"
(map (content,contentText,currentTs,) itemIds)
#endif
createCommand :: DB.Connection -> User -> Maybe Int64 -> CommandFunction -> IO CommandId
createCommand db User {userId} connId commandFunction = do
currentTs <- getCurrentTime
@@ -6647,6 +6647,10 @@ Error: SQLite3 returned ErrorError while attempting to perform prepare "explain
Query: DELETE FROM app_settings
Plan:
Query: DELETE FROM calls WHERE call_ts < ? RETURNING chat_item_id
Plan:
SCAN calls
Query: DELETE FROM calls WHERE user_id = ? AND contact_id = ?
Plan:
SEARCH calls USING INDEX idx_calls_contact_id (contact_id=?)
@@ -7378,6 +7382,14 @@ Query: SELECT chat_item_id FROM chat_items WHERE group_scope_tag = 'member_suppo
Plan:
SCAN chat_items
Query: SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '!2 SB-8H8V3-PF8CV-PQMA2-A54M3!%'
Plan:
SCAN chat_items
Query: SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '!2 SB-Y14GX-Z83KW-0E3PK-DEN7X!%'
Plan:
SCAN chat_items
Query: SELECT chat_item_id FROM chat_items WHERE item_text LIKE '%' || ? || '%' ORDER BY chat_item_id DESC LIMIT 1
Plan:
SCAN chat_items
@@ -7486,6 +7498,10 @@ Query: SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND loc
Plan:
SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?)
Query: SELECT count(1) FROM calls
Plan:
SCAN calls USING COVERING INDEX idx_calls_contact_id
Query: SELECT count(1) FROM chat_items WHERE chat_item_id > ?
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid>?)
@@ -7676,6 +7692,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1
Plan:
SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?)
Query: SELECT purchase_key FROM badge_code_redemptions
Plan:
SCAN badge_code_redemptions
Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connection_id = ?
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
@@ -7796,6 +7816,10 @@ Query: UPDATE badge_purchases SET next_wake_at = ? WHERE badge_purchase_id = ?
Plan:
SEARCH badge_purchases USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE chat_items SET item_content = ?, item_text = ?, updated_at = ? WHERE chat_item_id = ?
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
Query: UPDATE chat_items SET item_msg_body = ?, item_chat_binding = ?, item_signatures = ?, item_signed_by_group_member_id = ? WHERE chat_item_id = ? AND include_in_history = 1
Plan:
SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?)
+5 -2
View File
@@ -16,6 +16,7 @@ module Simplex.Chat.Web
webPreviewWorker,
writeCorsConfig,
removeStaleFiles,
publicGroupIdFileName,
channelContentChanged,
channelProfileUpdated,
channelRemoved,
@@ -416,9 +417,11 @@ removeStaleFiles dir activeFiles = do
let f' = if takeExtension f == ".tmp" then dropExtension f else f
base = dropExtension f'
in takeExtension f' == ".json" && not (null base) && all isBase64Url base
isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
isBase64Url c = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '='
allFiles <- S.filter isPreviewFile . S.fromList <$> listDirectory dir
mapM_ (\f -> removeFile (dir </> f)) $ S.difference allFiles activeFiles
forM_ (S.difference allFiles activeFiles) $ \f ->
removeFile (dir </> f) `catchOwn'` \(e :: SomeException) ->
logError $ "web preview: error removing stale file " <> T.pack f <> ": " <> tshow e
toFormattedText :: Text -> Maybe MarkdownList
toFormattedText t = case parseMaybeMarkdownList t of