mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
core: store/get remote files (#3289)
* core: store remote files (wip) * fix/test store remote file * get remote file * get file * validate remote file metadata before sending to controller * CLI commands, test * update store method
This commit is contained in:
parent
9fb2b7fe73
commit
d90da57f12
14 changed files with 543 additions and 227 deletions
|
@ -32,6 +32,7 @@ library
|
||||||
Simplex.Chat.Call
|
Simplex.Chat.Call
|
||||||
Simplex.Chat.Controller
|
Simplex.Chat.Controller
|
||||||
Simplex.Chat.Core
|
Simplex.Chat.Core
|
||||||
|
Simplex.Chat.Files
|
||||||
Simplex.Chat.Help
|
Simplex.Chat.Help
|
||||||
Simplex.Chat.Markdown
|
Simplex.Chat.Markdown
|
||||||
Simplex.Chat.Messages
|
Simplex.Chat.Messages
|
||||||
|
@ -131,6 +132,7 @@ library
|
||||||
Simplex.Chat.Remote.Discovery
|
Simplex.Chat.Remote.Discovery
|
||||||
Simplex.Chat.Remote.Multicast
|
Simplex.Chat.Remote.Multicast
|
||||||
Simplex.Chat.Remote.Protocol
|
Simplex.Chat.Remote.Protocol
|
||||||
|
Simplex.Chat.Remote.Transport
|
||||||
Simplex.Chat.Remote.Types
|
Simplex.Chat.Remote.Types
|
||||||
Simplex.Chat.Store
|
Simplex.Chat.Store
|
||||||
Simplex.Chat.Store.Connections
|
Simplex.Chat.Store.Connections
|
||||||
|
|
|
@ -55,6 +55,7 @@ import qualified Database.SQLite.Simple as SQL
|
||||||
import Simplex.Chat.Archive
|
import Simplex.Chat.Archive
|
||||||
import Simplex.Chat.Call
|
import Simplex.Chat.Call
|
||||||
import Simplex.Chat.Controller
|
import Simplex.Chat.Controller
|
||||||
|
import Simplex.Chat.Files
|
||||||
import Simplex.Chat.Markdown
|
import Simplex.Chat.Markdown
|
||||||
import Simplex.Chat.Messages
|
import Simplex.Chat.Messages
|
||||||
import Simplex.Chat.Messages.CIContent
|
import Simplex.Chat.Messages.CIContent
|
||||||
|
@ -104,7 +105,7 @@ import Simplex.Messaging.Transport.Client (defaultSocksProxy)
|
||||||
import Simplex.Messaging.Util
|
import Simplex.Messaging.Util
|
||||||
import Simplex.Messaging.Version
|
import Simplex.Messaging.Version
|
||||||
import System.Exit (exitFailure, exitSuccess)
|
import System.Exit (exitFailure, exitSuccess)
|
||||||
import System.FilePath (combine, splitExtensions, takeFileName, (</>))
|
import System.FilePath (takeFileName, (</>))
|
||||||
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout)
|
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, stdout)
|
||||||
import System.Random (randomRIO)
|
import System.Random (randomRIO)
|
||||||
import Text.Read (readMaybe)
|
import Text.Read (readMaybe)
|
||||||
|
@ -213,6 +214,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
|
||||||
currentCalls <- atomically TM.empty
|
currentCalls <- atomically TM.empty
|
||||||
localDeviceName <- newTVarIO "" -- TODO set in config
|
localDeviceName <- newTVarIO "" -- TODO set in config
|
||||||
remoteHostSessions <- atomically TM.empty
|
remoteHostSessions <- atomically TM.empty
|
||||||
|
remoteHostsFolder <- newTVarIO Nothing
|
||||||
remoteCtrlSession <- newTVarIO Nothing
|
remoteCtrlSession <- newTVarIO Nothing
|
||||||
filesFolder <- newTVarIO optFilesFolder
|
filesFolder <- newTVarIO optFilesFolder
|
||||||
chatStoreChanged <- newTVarIO False
|
chatStoreChanged <- newTVarIO False
|
||||||
|
@ -246,6 +248,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen
|
||||||
currentCalls,
|
currentCalls,
|
||||||
localDeviceName,
|
localDeviceName,
|
||||||
remoteHostSessions,
|
remoteHostSessions,
|
||||||
|
remoteHostsFolder,
|
||||||
remoteCtrlSession,
|
remoteCtrlSession,
|
||||||
config,
|
config,
|
||||||
filesFolder,
|
filesFolder,
|
||||||
|
@ -394,7 +397,7 @@ execChatCommand rh s = do
|
||||||
case parseChatCommand s of
|
case parseChatCommand s of
|
||||||
Left e -> pure $ chatCmdError u e
|
Left e -> pure $ chatCmdError u e
|
||||||
Right cmd -> case rh of
|
Right cmd -> case rh of
|
||||||
Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId s
|
Just remoteHostId | allowRemoteCommand cmd -> execRemoteCommand u remoteHostId cmd s
|
||||||
_ -> execChatCommand_ u cmd
|
_ -> execChatCommand_ u cmd
|
||||||
|
|
||||||
execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse
|
execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse
|
||||||
|
@ -403,8 +406,8 @@ execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` c
|
||||||
execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse
|
execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse
|
||||||
execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd
|
execChatCommand_ u cmd = handleCommandError u $ processChatCommand cmd
|
||||||
|
|
||||||
execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ByteString -> m ChatResponse
|
execRemoteCommand :: ChatMonad' m => Maybe User -> RemoteHostId -> ChatCommand -> ByteString -> m ChatResponse
|
||||||
execRemoteCommand u rhId s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh s
|
execRemoteCommand u rhId cmd s = handleCommandError u $ getRemoteHostSession rhId >>= \rh -> processRemoteCommand rhId rh cmd s
|
||||||
|
|
||||||
handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse
|
handleCommandError :: ChatMonad' m => Maybe User -> ExceptT ChatError m ChatResponse -> m ChatResponse
|
||||||
handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError))
|
handleCommandError u a = either (CRChatCmdError u) id <$> (runExceptT a `E.catch` (pure . Left . mkChatError))
|
||||||
|
@ -542,6 +545,10 @@ processChatCommand = \case
|
||||||
createDirectoryIfMissing True ff
|
createDirectoryIfMissing True ff
|
||||||
asks filesFolder >>= atomically . (`writeTVar` Just ff)
|
asks filesFolder >>= atomically . (`writeTVar` Just ff)
|
||||||
ok_
|
ok_
|
||||||
|
SetRemoteHostsFolder rf -> do
|
||||||
|
createDirectoryIfMissing True rf
|
||||||
|
chatWriteVar remoteHostsFolder $ Just rf
|
||||||
|
ok_
|
||||||
APISetXFTPConfig cfg -> do
|
APISetXFTPConfig cfg -> do
|
||||||
asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg)
|
asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg)
|
||||||
ok_
|
ok_
|
||||||
|
@ -1795,15 +1802,15 @@ processChatCommand = \case
|
||||||
asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_
|
asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_
|
||||||
SendFile chatName f -> withUser $ \user -> do
|
SendFile chatName f -> withUser $ \user -> do
|
||||||
chatRef <- getChatRef user chatName
|
chatRef <- getChatRef user chatName
|
||||||
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCFile "")
|
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCFile "")
|
||||||
SendImage chatName f -> withUser $ \user -> do
|
SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do
|
||||||
chatRef <- getChatRef user chatName
|
chatRef <- getChatRef user chatName
|
||||||
filePath <- toFSFilePath f
|
filePath <- toFSFilePath fPath
|
||||||
unless (any (`isSuffixOf` map toLower f) imageExtensions) $ throwChatError CEFileImageType {filePath}
|
unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath}
|
||||||
fileSize <- getFileSize filePath
|
fileSize <- getFileSize filePath
|
||||||
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
|
unless (fileSize <= maxImageSize) $ throwChatError CEFileImageSize {filePath}
|
||||||
-- TODO include file description for preview
|
-- TODO include file description for preview
|
||||||
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just $ CF.plain f) Nothing (MCImage "" fixedImagePreview)
|
processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview)
|
||||||
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
|
ForwardFile chatName fileId -> forwardFile chatName fileId SendFile
|
||||||
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
|
ForwardImage chatName fileId -> forwardFile chatName fileId SendImage
|
||||||
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
|
SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO"
|
||||||
|
@ -1905,19 +1912,21 @@ processChatCommand = \case
|
||||||
let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_
|
let pref = uncurry TimedMessagesGroupPreference $ maybe (FEOff, Just 86400) (\ttl -> (FEOn, Just ttl)) ttl_
|
||||||
updateGroupProfileByName gName $ \p ->
|
updateGroupProfileByName gName $ \p ->
|
||||||
p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p}
|
p {groupPreferences = Just . setGroupPreference' SGFTimedMessages pref $ groupPreferences p}
|
||||||
SetLocalDeviceName name -> withUser $ \_ -> chatWriteVar localDeviceName name >> ok_
|
SetLocalDeviceName name -> withUser_ $ chatWriteVar localDeviceName name >> ok_
|
||||||
CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost
|
CreateRemoteHost -> CRRemoteHostCreated <$> createRemoteHost
|
||||||
ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts
|
ListRemoteHosts -> CRRemoteHostList <$> listRemoteHosts
|
||||||
StartRemoteHost rh -> startRemoteHost rh >> ok_
|
StartRemoteHost rh -> startRemoteHost rh >> ok_
|
||||||
StopRemoteHost rh -> closeRemoteHostSession rh >> ok_
|
StopRemoteHost rh -> closeRemoteHostSession rh >> ok_
|
||||||
DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_
|
DeleteRemoteHost rh -> deleteRemoteHost rh >> ok_
|
||||||
StartRemoteCtrl -> startRemoteCtrl (execChatCommand Nothing) >> ok_
|
StoreRemoteFile rh encrypted_ localPath -> CRRemoteFileStored rh <$> storeRemoteFile rh encrypted_ localPath
|
||||||
RegisterRemoteCtrl oob -> CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob)
|
GetRemoteFile rh rf -> getRemoteFile rh rf >> ok_
|
||||||
AcceptRemoteCtrl rc -> acceptRemoteCtrl rc >> ok_
|
StartRemoteCtrl -> withUser_ $ startRemoteCtrl (execChatCommand Nothing) >> ok_
|
||||||
RejectRemoteCtrl rc -> rejectRemoteCtrl rc >> ok_
|
RegisterRemoteCtrl oob -> withUser_ $ CRRemoteCtrlRegistered <$> withStore' (`insertRemoteCtrl` oob)
|
||||||
StopRemoteCtrl -> stopRemoteCtrl >> ok_
|
AcceptRemoteCtrl rc -> withUser_ $ acceptRemoteCtrl rc >> ok_
|
||||||
ListRemoteCtrls -> CRRemoteCtrlList <$> listRemoteCtrls
|
RejectRemoteCtrl rc -> withUser_ $ rejectRemoteCtrl rc >> ok_
|
||||||
DeleteRemoteCtrl rc -> deleteRemoteCtrl rc >> ok_
|
StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_
|
||||||
|
ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls
|
||||||
|
DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_
|
||||||
QuitChat -> liftIO exitSuccess
|
QuitChat -> liftIO exitSuccess
|
||||||
ShowVersion -> do
|
ShowVersion -> do
|
||||||
let versionInfo = coreVersionInfo $(simplexmqCommitQ)
|
let versionInfo = coreVersionInfo $(simplexmqCommitQ)
|
||||||
|
@ -2173,14 +2182,14 @@ processChatCommand = \case
|
||||||
withServerProtocol p action = case userProtocol p of
|
withServerProtocol p action = case userProtocol p of
|
||||||
Just Dict -> action
|
Just Dict -> action
|
||||||
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
|
_ -> throwChatError $ CEServerProtocol $ AProtocolType p
|
||||||
forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse
|
forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> m ChatResponse
|
||||||
forwardFile chatName fileId sendCommand = withUser $ \user -> do
|
forwardFile chatName fileId sendCommand = withUser $ \user -> do
|
||||||
withStore (\db -> getFileTransfer db user fileId) >>= \case
|
withStore (\db -> getFileTransfer db user fileId) >>= \case
|
||||||
FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> forward filePath
|
FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs
|
||||||
FTSnd {fileTransferMeta = FileTransferMeta {filePath}} -> forward filePath
|
FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \f -> f.cryptoArgs
|
||||||
_ -> throwChatError CEFileNotReceived {fileId}
|
_ -> throwChatError CEFileNotReceived {fileId}
|
||||||
where
|
where
|
||||||
forward = processChatCommand . sendCommand chatName
|
forward path cfArgs = processChatCommand . sendCommand chatName $ CryptoFile path cfArgs
|
||||||
getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId)
|
getGroupAndMemberId :: User -> GroupName -> ContactName -> m (GroupId, GroupMemberId)
|
||||||
getGroupAndMemberId user gName groupMemberName =
|
getGroupAndMemberId user gName groupMemberName =
|
||||||
withStore $ \db -> do
|
withStore $ \db -> do
|
||||||
|
@ -2575,10 +2584,9 @@ startReceivingFile user fileId = do
|
||||||
getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath
|
getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath
|
||||||
getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of
|
getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of
|
||||||
Nothing ->
|
Nothing ->
|
||||||
asks filesFolder >>= readTVarIO >>= \case
|
chatReadVar filesFolder >>= \case
|
||||||
Nothing -> do
|
Nothing ->
|
||||||
dir <- (`combine` "Downloads") <$> getHomeDirectory
|
getDefaultFilesFolder
|
||||||
ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory
|
|
||||||
>>= (`uniqueCombine` fn)
|
>>= (`uniqueCombine` fn)
|
||||||
>>= createEmptyFile
|
>>= createEmptyFile
|
||||||
Just filesFolder ->
|
Just filesFolder ->
|
||||||
|
@ -2607,18 +2615,6 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of
|
||||||
getTmpHandle :: FilePath -> m Handle
|
getTmpHandle :: FilePath -> m Handle
|
||||||
getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show)
|
getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show)
|
||||||
|
|
||||||
uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath
|
|
||||||
uniqueCombine filePath fileName = tryCombine (0 :: Int)
|
|
||||||
where
|
|
||||||
tryCombine n =
|
|
||||||
let (name, ext) = splitExtensions fileName
|
|
||||||
suffix = if n == 0 then "" else "_" <> show n
|
|
||||||
f = filePath `combine` (name <> suffix <> ext)
|
|
||||||
in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f)
|
|
||||||
|
|
||||||
getChatTempDirectory :: ChatMonad m => m FilePath
|
|
||||||
getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure
|
|
||||||
|
|
||||||
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact
|
acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact
|
||||||
acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do
|
acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do
|
||||||
subMode <- chatReadVar subscriptionMode
|
subMode <- chatReadVar subscriptionMode
|
||||||
|
@ -5575,6 +5571,9 @@ withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse
|
||||||
withUser action = withUser' $ \user ->
|
withUser action = withUser' $ \user ->
|
||||||
ifM chatStarted (action user) (throwChatError CEChatNotStarted)
|
ifM chatStarted (action user) (throwChatError CEChatNotStarted)
|
||||||
|
|
||||||
|
withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse
|
||||||
|
withUser_ = withUser . const
|
||||||
|
|
||||||
withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse
|
withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse
|
||||||
withUserId userId action = withUser $ \user -> do
|
withUserId userId action = withUser $ \user -> do
|
||||||
checkSameUser userId user
|
checkSameUser userId user
|
||||||
|
@ -5635,6 +5634,7 @@ chatCommandP =
|
||||||
"/_resubscribe all" $> ResubscribeAllConnections,
|
"/_resubscribe all" $> ResubscribeAllConnections,
|
||||||
"/_temp_folder " *> (SetTempFolder <$> filePath),
|
"/_temp_folder " *> (SetTempFolder <$> filePath),
|
||||||
("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath),
|
("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath),
|
||||||
|
"/remote_hosts_folder " *> (SetRemoteHostsFolder <$> filePath),
|
||||||
"/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))),
|
"/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))),
|
||||||
"/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))),
|
"/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))),
|
||||||
"/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP),
|
"/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP),
|
||||||
|
@ -5809,8 +5809,8 @@ chatCommandP =
|
||||||
"/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)),
|
"/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)),
|
||||||
"/show " *> (ShowChatItem . Just <$> A.decimal),
|
"/show " *> (ShowChatItem . Just <$> A.decimal),
|
||||||
"/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP),
|
"/item info " *> (ShowChatItemInfo <$> chatNameP <* A.space <*> msgTextP),
|
||||||
("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> filePath),
|
("/file " <|> "/f ") *> (SendFile <$> chatNameP' <* A.space <*> cryptoFileP),
|
||||||
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> filePath),
|
("/image " <|> "/img ") *> (SendImage <$> chatNameP' <* A.space <*> cryptoFileP),
|
||||||
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
|
("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal),
|
||||||
("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal),
|
("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal),
|
||||||
("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath),
|
("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath),
|
||||||
|
@ -5858,6 +5858,8 @@ chatCommandP =
|
||||||
"/start remote host " *> (StartRemoteHost <$> A.decimal),
|
"/start remote host " *> (StartRemoteHost <$> A.decimal),
|
||||||
"/stop remote host " *> (StopRemoteHost <$> A.decimal),
|
"/stop remote host " *> (StopRemoteHost <$> A.decimal),
|
||||||
"/delete remote host " *> (DeleteRemoteHost <$> A.decimal),
|
"/delete remote host " *> (DeleteRemoteHost <$> A.decimal),
|
||||||
|
"/store remote file " *> (StoreRemoteFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <* A.space <*> filePath),
|
||||||
|
"/get remote file " *> (GetRemoteFile <$> A.decimal <* A.space <*> jsonP),
|
||||||
"/start remote ctrl" $> StartRemoteCtrl,
|
"/start remote ctrl" $> StartRemoteCtrl,
|
||||||
"/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)),
|
"/register remote ctrl " *> (RegisterRemoteCtrl <$> (RemoteCtrlOOB <$> strP <* A.space <*> textP)),
|
||||||
"/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP),
|
"/_register remote ctrl " *> (RegisterRemoteCtrl <$> jsonP),
|
||||||
|
@ -5932,6 +5934,10 @@ chatCommandP =
|
||||||
msgTextP = jsonP <|> textP
|
msgTextP = jsonP <|> textP
|
||||||
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString
|
||||||
filePath = stringP
|
filePath = stringP
|
||||||
|
cryptoFileP = do
|
||||||
|
cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP)
|
||||||
|
path <- filePath
|
||||||
|
pure $ CryptoFile path cfArgs
|
||||||
memberRole =
|
memberRole =
|
||||||
A.choice
|
A.choice
|
||||||
[ " owner" $> GROwner,
|
[ " owner" $> GROwner,
|
||||||
|
|
|
@ -9,6 +9,7 @@ module Simplex.Chat.Archive
|
||||||
importArchive,
|
importArchive,
|
||||||
deleteStorage,
|
deleteStorage,
|
||||||
sqlCipherExport,
|
sqlCipherExport,
|
||||||
|
archiveFilesFolder,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
|
|
|
@ -178,6 +178,7 @@ data ChatController = ChatController
|
||||||
currentCalls :: TMap ContactId Call,
|
currentCalls :: TMap ContactId Call,
|
||||||
localDeviceName :: TVar Text,
|
localDeviceName :: TVar Text,
|
||||||
remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts
|
remoteHostSessions :: TMap RemoteHostId RemoteHostSession, -- All the active remote hosts
|
||||||
|
remoteHostsFolder :: TVar (Maybe FilePath), -- folder for remote hosts data
|
||||||
remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers
|
remoteCtrlSession :: TVar (Maybe RemoteCtrlSession), -- Supervisor process for hosted controllers
|
||||||
config :: ChatConfig,
|
config :: ChatConfig,
|
||||||
filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps,
|
filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps,
|
||||||
|
@ -224,6 +225,7 @@ data ChatCommand
|
||||||
| ResubscribeAllConnections
|
| ResubscribeAllConnections
|
||||||
| SetTempFolder FilePath
|
| SetTempFolder FilePath
|
||||||
| SetFilesFolder FilePath
|
| SetFilesFolder FilePath
|
||||||
|
| SetRemoteHostsFolder FilePath
|
||||||
| APISetXFTPConfig (Maybe XFTPFileConfig)
|
| APISetXFTPConfig (Maybe XFTPFileConfig)
|
||||||
| APISetEncryptLocalFiles Bool
|
| APISetEncryptLocalFiles Bool
|
||||||
| SetContactMergeEnabled Bool
|
| SetContactMergeEnabled Bool
|
||||||
|
@ -393,8 +395,8 @@ data ChatCommand
|
||||||
| ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI)
|
| ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI)
|
||||||
| ShowChatItemInfo ChatName Text
|
| ShowChatItemInfo ChatName Text
|
||||||
| ShowLiveItems Bool
|
| ShowLiveItems Bool
|
||||||
| SendFile ChatName FilePath
|
| SendFile ChatName CryptoFile
|
||||||
| SendImage ChatName FilePath
|
| SendImage ChatName CryptoFile
|
||||||
| ForwardFile ChatName FileTransferId
|
| ForwardFile ChatName FileTransferId
|
||||||
| ForwardImage ChatName FileTransferId
|
| ForwardImage ChatName FileTransferId
|
||||||
| SendFileDescription ChatName FilePath
|
| SendFileDescription ChatName FilePath
|
||||||
|
@ -419,6 +421,8 @@ data ChatCommand
|
||||||
-- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host
|
-- | SwitchRemoteHost (Maybe RemoteHostId) -- ^ Switch current remote host
|
||||||
| StopRemoteHost RemoteHostId -- ^ Shut down a running session
|
| StopRemoteHost RemoteHostId -- ^ Shut down a running session
|
||||||
| DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data
|
| DeleteRemoteHost RemoteHostId -- ^ Unregister remote host and remove its data
|
||||||
|
| StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath}
|
||||||
|
| GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile}
|
||||||
| StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers
|
| StartRemoteCtrl -- ^ Start listening for announcements from all registered controllers
|
||||||
| RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake
|
| RegisterRemoteCtrl RemoteCtrlOOB -- ^ Register OOB data for satellite discovery and handshake
|
||||||
| ListRemoteCtrls
|
| ListRemoteCtrls
|
||||||
|
@ -440,22 +444,27 @@ allowRemoteCommand = \case
|
||||||
StartChat {} -> False
|
StartChat {} -> False
|
||||||
APIStopChat -> False
|
APIStopChat -> False
|
||||||
APIActivateChat -> False
|
APIActivateChat -> False
|
||||||
APISuspendChat {} -> False
|
APISuspendChat _ -> False
|
||||||
SetTempFolder {} -> False
|
SetTempFolder _ -> False
|
||||||
QuitChat -> False
|
QuitChat -> False
|
||||||
CreateRemoteHost -> False
|
CreateRemoteHost -> False
|
||||||
ListRemoteHosts -> False
|
ListRemoteHosts -> False
|
||||||
StartRemoteHost {} -> False
|
StartRemoteHost _ -> False
|
||||||
-- SwitchRemoteHost {} -> False
|
-- SwitchRemoteHost {} -> False
|
||||||
StopRemoteHost {} -> False
|
StoreRemoteFile {} -> False
|
||||||
DeleteRemoteHost {} -> False
|
GetRemoteFile {} -> False
|
||||||
|
StopRemoteHost _ -> False
|
||||||
|
DeleteRemoteHost _ -> False
|
||||||
RegisterRemoteCtrl {} -> False
|
RegisterRemoteCtrl {} -> False
|
||||||
StartRemoteCtrl -> False
|
StartRemoteCtrl -> False
|
||||||
ListRemoteCtrls -> False
|
ListRemoteCtrls -> False
|
||||||
AcceptRemoteCtrl {} -> False
|
AcceptRemoteCtrl _ -> False
|
||||||
RejectRemoteCtrl {} -> False
|
RejectRemoteCtrl _ -> False
|
||||||
StopRemoteCtrl -> False
|
StopRemoteCtrl -> False
|
||||||
DeleteRemoteCtrl {} -> False
|
DeleteRemoteCtrl _ -> False
|
||||||
|
ExecChatStoreSQL _ -> False
|
||||||
|
ExecAgentStoreSQL _ -> False
|
||||||
|
SlowSQLQueries -> False
|
||||||
_ -> True
|
_ -> True
|
||||||
|
|
||||||
data ChatResponse
|
data ChatResponse
|
||||||
|
@ -627,6 +636,7 @@ data ChatResponse
|
||||||
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
|
| CRRemoteHostList {remoteHosts :: [RemoteHostInfo]}
|
||||||
| CRRemoteHostConnected {remoteHost :: RemoteHostInfo}
|
| CRRemoteHostConnected {remoteHost :: RemoteHostInfo}
|
||||||
| CRRemoteHostStopped {remoteHostId :: RemoteHostId}
|
| CRRemoteHostStopped {remoteHostId :: RemoteHostId}
|
||||||
|
| CRRemoteFileStored {remoteHostId :: RemoteHostId, remoteFileSource :: CryptoFile}
|
||||||
| CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]}
|
| CRRemoteCtrlList {remoteCtrls :: [RemoteCtrlInfo]}
|
||||||
| CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo}
|
| CRRemoteCtrlRegistered {remoteCtrl :: RemoteCtrlInfo}
|
||||||
| CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation
|
| CRRemoteCtrlAnnounce {fingerprint :: C.KeyHash} -- unregistered fingerprint, needs confirmation
|
||||||
|
|
27
src/Simplex/Chat/Files.hs
Normal file
27
src/Simplex/Chat/Files.hs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
|
||||||
|
module Simplex.Chat.Files where
|
||||||
|
|
||||||
|
import Control.Monad.IO.Class
|
||||||
|
import Simplex.Chat.Controller
|
||||||
|
import Simplex.Messaging.Util (ifM)
|
||||||
|
import System.FilePath (splitExtensions, combine)
|
||||||
|
import UnliftIO.Directory (doesFileExist, getTemporaryDirectory, getHomeDirectory, doesDirectoryExist)
|
||||||
|
|
||||||
|
uniqueCombine :: MonadIO m => FilePath -> String -> m FilePath
|
||||||
|
uniqueCombine fPath fName = tryCombine (0 :: Int)
|
||||||
|
where
|
||||||
|
tryCombine n =
|
||||||
|
let (name, ext) = splitExtensions fName
|
||||||
|
suffix = if n == 0 then "" else "_" <> show n
|
||||||
|
f = fPath `combine` (name <> suffix <> ext)
|
||||||
|
in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f)
|
||||||
|
|
||||||
|
getChatTempDirectory :: ChatMonad m => m FilePath
|
||||||
|
getChatTempDirectory = chatReadVar tempDirectory >>= maybe getTemporaryDirectory pure
|
||||||
|
|
||||||
|
getDefaultFilesFolder :: ChatMonad m => m FilePath
|
||||||
|
getDefaultFilesFolder = do
|
||||||
|
dir <- (`combine` "Downloads") <$> getHomeDirectory
|
||||||
|
ifM (doesDirectoryExist dir) (pure dir) getChatTempDirectory
|
|
@ -24,6 +24,7 @@ import qualified Data.Aeson.TH as JQ
|
||||||
import qualified Data.Attoparsec.ByteString.Char8 as A
|
import qualified Data.Attoparsec.ByteString.Char8 as A
|
||||||
import qualified Data.ByteString.Base64 as B64
|
import qualified Data.ByteString.Base64 as B64
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
|
import Data.Char (isSpace)
|
||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
import Data.Maybe (fromMaybe, isJust, isNothing)
|
import Data.Maybe (fromMaybe, isJust, isNothing)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
|
@ -53,7 +54,7 @@ data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection
|
||||||
data ChatName = ChatName {chatType :: ChatType, chatName :: Text}
|
data ChatName = ChatName {chatType :: ChatType, chatName :: Text}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
chatTypeStr :: ChatType -> String
|
chatTypeStr :: ChatType -> Text
|
||||||
chatTypeStr = \case
|
chatTypeStr = \case
|
||||||
CTDirect -> "@"
|
CTDirect -> "@"
|
||||||
CTGroup -> "#"
|
CTGroup -> "#"
|
||||||
|
@ -61,7 +62,7 @@ chatTypeStr = \case
|
||||||
CTContactConnection -> ":"
|
CTContactConnection -> ":"
|
||||||
|
|
||||||
chatNameStr :: ChatName -> String
|
chatNameStr :: ChatName -> String
|
||||||
chatNameStr (ChatName cType name) = chatTypeStr cType <> T.unpack name
|
chatNameStr (ChatName cType name) = T.unpack $ chatTypeStr cType <> if T.any isSpace name then "'" <> name <> "'" else name
|
||||||
|
|
||||||
data ChatRef = ChatRef ChatType Int64
|
data ChatRef = ChatRef ChatType Int64
|
||||||
deriving (Eq, Show, Ord)
|
deriving (Eq, Show, Ord)
|
||||||
|
|
|
@ -99,7 +99,7 @@ chatEncryptFile fromPath toPath =
|
||||||
either WFError WFResult <$> runCatchExceptT encrypt
|
either WFError WFResult <$> runCatchExceptT encrypt
|
||||||
where
|
where
|
||||||
encrypt = do
|
encrypt = do
|
||||||
cfArgs <- liftIO $ CF.randomArgs
|
cfArgs <- liftIO CF.randomArgs
|
||||||
encryptFile fromPath toPath cfArgs
|
encryptFile fromPath toPath cfArgs
|
||||||
pure cfArgs
|
pure cfArgs
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ import Control.Logger.Simple
|
||||||
import Control.Monad
|
import Control.Monad
|
||||||
import Control.Monad.Except
|
import Control.Monad.Except
|
||||||
import Control.Monad.IO.Class
|
import Control.Monad.IO.Class
|
||||||
import Control.Monad.Reader (asks)
|
import Control.Monad.Reader
|
||||||
import Control.Monad.STM (retry)
|
import Control.Monad.STM (retry)
|
||||||
import Crypto.Random (getRandomBytes)
|
import Crypto.Random (getRandomBytes)
|
||||||
import qualified Data.Aeson as J
|
import qualified Data.Aeson as J
|
||||||
|
@ -34,21 +34,35 @@ import Data.Word (Word32)
|
||||||
import Network.HTTP2.Server (responseStreaming)
|
import Network.HTTP2.Server (responseStreaming)
|
||||||
import qualified Network.HTTP.Types as N
|
import qualified Network.HTTP.Types as N
|
||||||
import Network.Socket (SockAddr (..), hostAddressToTuple)
|
import Network.Socket (SockAddr (..), hostAddressToTuple)
|
||||||
|
import Simplex.Chat.Archive (archiveFilesFolder)
|
||||||
import Simplex.Chat.Controller
|
import Simplex.Chat.Controller
|
||||||
|
import Simplex.Chat.Files
|
||||||
|
import Simplex.Chat.Messages (chatNameStr)
|
||||||
import qualified Simplex.Chat.Remote.Discovery as Discovery
|
import qualified Simplex.Chat.Remote.Discovery as Discovery
|
||||||
import Simplex.Chat.Remote.Protocol
|
import Simplex.Chat.Remote.Protocol
|
||||||
|
import Simplex.Chat.Remote.Transport
|
||||||
import Simplex.Chat.Remote.Types
|
import Simplex.Chat.Remote.Types
|
||||||
|
import Simplex.Chat.Store.Files
|
||||||
import Simplex.Chat.Store.Remote
|
import Simplex.Chat.Store.Remote
|
||||||
|
import Simplex.Chat.Store.Shared
|
||||||
|
import Simplex.Chat.Types (User (..))
|
||||||
|
import Simplex.Chat.Util (encryptFile)
|
||||||
|
import Simplex.FileTransfer.Description (FileDigest (..))
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
|
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||||
|
import qualified Simplex.Messaging.Crypto.File as CF
|
||||||
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
||||||
import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..))
|
|
||||||
import qualified Simplex.Messaging.TMap as TM
|
|
||||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||||
import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials)
|
import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials)
|
||||||
import Simplex.Messaging.Transport.HTTP2.File (hSendFile)
|
import Simplex.Messaging.Transport.HTTP2.File (hSendFile)
|
||||||
import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=))
|
import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..))
|
||||||
import System.FilePath ((</>))
|
import qualified Simplex.Messaging.TMap as TM
|
||||||
|
import Simplex.Messaging.Util (ifM, liftEitherError, liftEitherWith, liftError, liftIOEither, tryAllErrors, tshow, ($>>=), (<$$>))
|
||||||
|
import System.FilePath ((</>), takeFileName)
|
||||||
import UnliftIO
|
import UnliftIO
|
||||||
|
import UnliftIO.Directory (copyFile, createDirectoryIfMissing, renameFile)
|
||||||
|
import Data.Functor (($>))
|
||||||
|
import Control.Applicative ((<|>))
|
||||||
|
|
||||||
-- * Desktop side
|
-- * Desktop side
|
||||||
|
|
||||||
|
@ -110,7 +124,7 @@ startRemoteHost rhId = do
|
||||||
toView $ CRRemoteHostConnected RemoteHostInfo
|
toView $ CRRemoteHostConnected RemoteHostInfo
|
||||||
{ remoteHostId = rhId,
|
{ remoteHostId = rhId,
|
||||||
storePath = storePath,
|
storePath = storePath,
|
||||||
displayName = remoteDeviceName remoteHostClient,
|
displayName = hostDeviceName remoteHostClient,
|
||||||
remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName},
|
remoteCtrlOOB = RemoteCtrlOOB {fingerprint, displayName=rcName},
|
||||||
sessionActive = True
|
sessionActive = True
|
||||||
}
|
}
|
||||||
|
@ -178,9 +192,57 @@ deleteRemoteHost rhId = do
|
||||||
Nothing -> logWarn "Local file store not available while deleting remote host"
|
Nothing -> logWarn "Local file store not available while deleting remote host"
|
||||||
withStore' (`deleteRemoteHostRecord` rhId)
|
withStore' (`deleteRemoteHostRecord` rhId)
|
||||||
|
|
||||||
processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ByteString -> m ChatResponse
|
storeRemoteFile :: forall m. ChatMonad m => RemoteHostId -> Maybe Bool -> FilePath -> m CryptoFile
|
||||||
processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} s = liftRH remoteHostId $ remoteSend rhc s
|
storeRemoteFile rhId encrypted_ localPath = do
|
||||||
processRemoteCommand _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started"
|
RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId
|
||||||
|
case remoteHostClient of
|
||||||
|
Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing
|
||||||
|
Just c@RemoteHostClient {encryptHostFiles} -> do
|
||||||
|
let encrypt = fromMaybe encryptHostFiles encrypted_
|
||||||
|
cf@CryptoFile {filePath} <- if encrypt then encryptLocalFile else pure $ CF.plain localPath
|
||||||
|
filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath)
|
||||||
|
hf_ <- chatReadVar remoteHostsFolder
|
||||||
|
forM_ hf_ $ \hf -> do
|
||||||
|
let rhf = hf </> storePath </> archiveFilesFolder
|
||||||
|
hPath = rhf </> takeFileName filePath'
|
||||||
|
createDirectoryIfMissing True rhf
|
||||||
|
(if encrypt then renameFile else copyFile) filePath hPath
|
||||||
|
pure (cf :: CryptoFile) {filePath = filePath'}
|
||||||
|
where
|
||||||
|
encryptLocalFile :: m CryptoFile
|
||||||
|
encryptLocalFile = do
|
||||||
|
tmpDir <- getChatTempDirectory
|
||||||
|
createDirectoryIfMissing True tmpDir
|
||||||
|
tmpFile <- tmpDir `uniqueCombine` takeFileName localPath
|
||||||
|
cfArgs <- liftIO CF.randomArgs
|
||||||
|
liftError (ChatError . CEFileWrite tmpFile) $ encryptFile localPath tmpFile cfArgs
|
||||||
|
pure $ CryptoFile tmpFile $ Just cfArgs
|
||||||
|
|
||||||
|
getRemoteFile :: ChatMonad m => RemoteHostId -> RemoteFile -> m ()
|
||||||
|
getRemoteFile rhId rf = do
|
||||||
|
RemoteHostSession {remoteHostClient, storePath} <- getRemoteHostSession rhId
|
||||||
|
case remoteHostClient of
|
||||||
|
Nothing -> throwError $ ChatErrorRemoteHost rhId RHMissing
|
||||||
|
Just c -> do
|
||||||
|
dir <- (</> storePath </> archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar remoteHostsFolder)
|
||||||
|
createDirectoryIfMissing True dir
|
||||||
|
liftRH rhId $ remoteGetFile c dir rf
|
||||||
|
|
||||||
|
processRemoteCommand :: ChatMonad m => RemoteHostId -> RemoteHostSession -> ChatCommand -> ByteString -> m ChatResponse
|
||||||
|
processRemoteCommand remoteHostId RemoteHostSession {remoteHostClient = Just rhc} cmd s = case cmd of
|
||||||
|
SendFile chatName f -> sendFile "/f" chatName f
|
||||||
|
SendImage chatName f -> sendFile "/img" chatName f
|
||||||
|
_ -> liftRH remoteHostId $ remoteSend rhc s
|
||||||
|
where
|
||||||
|
sendFile cmdName chatName (CryptoFile path cfArgs) = do
|
||||||
|
-- don't encrypt in host if already encrypted locally
|
||||||
|
CryptoFile path' cfArgs' <- storeRemoteFile remoteHostId (cfArgs $> False) path
|
||||||
|
let f = CryptoFile path' (cfArgs <|> cfArgs') -- use local or host encryption
|
||||||
|
liftRH remoteHostId $ remoteSend rhc $ B.unwords [cmdName, B.pack (chatNameStr chatName), cryptoFileStr f]
|
||||||
|
cryptoFileStr CryptoFile {filePath, cryptoArgs} =
|
||||||
|
maybe "" (\(CFArgs key nonce) -> "key=" <> strEncode key <> " nonce=" <> strEncode nonce <> " ") cryptoArgs
|
||||||
|
<> encodeUtf8 (T.pack filePath)
|
||||||
|
processRemoteCommand _ _ _ _ = pure $ chatCmdError Nothing "remote command sent before session started"
|
||||||
|
|
||||||
liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a
|
liftRH :: ChatMonad m => RemoteHostId -> ExceptT RemoteProtocolError IO a -> m a
|
||||||
liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError)
|
liftRH rhId = liftError (ChatErrorRemoteHost rhId . RHProtocolError)
|
||||||
|
@ -218,20 +280,24 @@ handleRemoteCommand :: forall m . ChatMonad m => (ByteString -> m ChatResponse)
|
||||||
handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do
|
handleRemoteCommand execChatCommand remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do
|
||||||
logDebug "handleRemoteCommand"
|
logDebug "handleRemoteCommand"
|
||||||
liftRC (tryRemoteError parseRequest) >>= \case
|
liftRC (tryRemoteError parseRequest) >>= \case
|
||||||
Right (getNext, rc) -> processCommand getNext rc `catchAny` (reply . RRProtocolError . RPEException . tshow)
|
Right (getNext, rc) -> do
|
||||||
|
chatReadVar currentUser >>= \case
|
||||||
|
Nothing -> replyError $ ChatError CENoActiveUser
|
||||||
|
Just user -> processCommand user getNext rc `catchChatError` replyError
|
||||||
Left e -> reply $ RRProtocolError e
|
Left e -> reply $ RRProtocolError e
|
||||||
where
|
where
|
||||||
parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand)
|
parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand)
|
||||||
parseRequest = do
|
parseRequest = do
|
||||||
(header, getNext) <- parseHTTP2Body request reqBody
|
(header, getNext) <- parseHTTP2Body request reqBody
|
||||||
(getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header)
|
(getNext,) <$> liftEitherWith (RPEInvalidJSON . T.pack) (J.eitherDecodeStrict' header)
|
||||||
processCommand :: GetChunk -> RemoteCommand -> m ()
|
replyError = reply . RRChatResponse . CRChatCmdError Nothing
|
||||||
processCommand getNext = \case
|
processCommand :: User -> GetChunk -> RemoteCommand -> m ()
|
||||||
|
processCommand user getNext = \case
|
||||||
RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply
|
RCHello {deviceName = desktopName} -> handleHello desktopName >>= reply
|
||||||
RCSend {command} -> handleSend execChatCommand command >>= reply
|
RCSend {command} -> handleSend execChatCommand command >>= reply
|
||||||
RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply
|
RCRecv {wait = time} -> handleRecv time remoteOutputQ >>= reply
|
||||||
RCStoreFile {fileSize, encrypt} -> handleStoreFile fileSize encrypt getNext >>= reply
|
RCStoreFile {fileName, fileSize, fileDigest} -> handleStoreFile fileName fileSize fileDigest getNext >>= reply
|
||||||
RCGetFile {filePath} -> handleGetFile filePath replyWith
|
RCGetFile {file} -> handleGetFile user file replyWith
|
||||||
reply :: RemoteResponse -> m ()
|
reply :: RemoteResponse -> m ()
|
||||||
reply = (`replyWith` \_ -> pure ())
|
reply = (`replyWith` \_ -> pure ())
|
||||||
replyWith :: Respond m
|
replyWith :: Respond m
|
||||||
|
@ -258,7 +324,8 @@ handleHello :: ChatMonad m => Text -> m RemoteResponse
|
||||||
handleHello desktopName = do
|
handleHello desktopName = do
|
||||||
logInfo $ "Hello from " <> tshow desktopName
|
logInfo $ "Hello from " <> tshow desktopName
|
||||||
mobileName <- chatReadVar localDeviceName
|
mobileName <- chatReadVar localDeviceName
|
||||||
pure RRHello {encoding = localEncoding, deviceName = mobileName}
|
encryptFiles <- chatReadVar encryptLocalFiles
|
||||||
|
pure RRHello {encoding = localEncoding, deviceName = mobileName, encryptFiles}
|
||||||
|
|
||||||
handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse
|
handleSend :: ChatMonad m => (ByteString -> m ChatResponse) -> Text -> m RemoteResponse
|
||||||
handleSend execChatCommand command = do
|
handleSend execChatCommand command = do
|
||||||
|
@ -272,20 +339,36 @@ handleRecv time events = do
|
||||||
logDebug $ "Recv: " <> tshow time
|
logDebug $ "Recv: " <> tshow time
|
||||||
RRChatEvent <$> (timeout time . atomically $ readTBQueue events)
|
RRChatEvent <$> (timeout time . atomically $ readTBQueue events)
|
||||||
|
|
||||||
handleStoreFile :: ChatMonad m => Word32 -> Maybe Bool -> GetChunk -> m RemoteResponse
|
-- TODO this command could remember stored files and return IDs to allow removing files that are not needed.
|
||||||
handleStoreFile _fileSize _encrypt _getNext = error "TODO" <$ logError "TODO: handleStoreFile"
|
-- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files).
|
||||||
|
handleStoreFile :: forall m. ChatMonad m => FilePath -> Word32 -> FileDigest -> GetChunk -> m RemoteResponse
|
||||||
|
handleStoreFile fileName fileSize fileDigest getChunk =
|
||||||
|
either RRProtocolError RRFileStored <$> (chatReadVar filesFolder >>= storeFile)
|
||||||
|
where
|
||||||
|
storeFile :: Maybe FilePath -> m (Either RemoteProtocolError FilePath)
|
||||||
|
storeFile = \case
|
||||||
|
Just ff -> takeFileName <$$> storeFileTo ff
|
||||||
|
Nothing -> storeFileTo =<< getDefaultFilesFolder
|
||||||
|
storeFileTo :: FilePath -> m (Either RemoteProtocolError FilePath)
|
||||||
|
storeFileTo dir = liftRC . tryRemoteError $ do
|
||||||
|
filePath <- dir `uniqueCombine` fileName
|
||||||
|
receiveRemoteFile getChunk fileSize fileDigest filePath
|
||||||
|
pure filePath
|
||||||
|
|
||||||
handleGetFile :: ChatMonad m => FilePath -> Respond m -> m ()
|
handleGetFile :: ChatMonad m => User -> RemoteFile -> Respond m -> m ()
|
||||||
handleGetFile path reply = do
|
handleGetFile User {userId} RemoteFile{userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do
|
||||||
logDebug $ "GetFile: " <> tshow path
|
logDebug $ "GetFile: " <> tshow filePath
|
||||||
withFile path ReadMode $ \h -> do
|
unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId}
|
||||||
fileSize' <- hFileSize h
|
path <- maybe filePath (</> filePath) <$> chatReadVar filesFolder
|
||||||
when (fileSize' > toInteger (maxBound :: Word32)) $ throwIO RPEFileTooLarge
|
withStore $ \db -> do
|
||||||
let fileSize = fromInteger fileSize'
|
cf <- getLocalCryptoFile db commandUserId fileId sent
|
||||||
reply RRFile {fileSize} $ \send -> hSendFile h send fileSize
|
unless (cf == cf') $ throwError $ SEFileNotFound fileId
|
||||||
|
liftRC (tryRemoteError $ getFileInfo path) >>= \case
|
||||||
|
Left e -> reply (RRProtocolError e) $ \_ -> pure ()
|
||||||
|
Right (fileSize, fileDigest) ->
|
||||||
|
withFile path ReadMode $ \h ->
|
||||||
|
reply RRFile {fileSize, fileDigest} $ \send -> hSendFile h send fileSize
|
||||||
|
|
||||||
-- TODO the problem with this code was that it wasn't clear where the recursion can happen,
|
|
||||||
-- by splitting receiving and processing to two functions it becomes clear
|
|
||||||
discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m ()
|
discoverRemoteCtrls :: ChatMonad m => TM.TMap C.KeyHash TransportHost -> m ()
|
||||||
discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process
|
discoverRemoteCtrls discovered = Discovery.withListener $ receive >=> process
|
||||||
where
|
where
|
||||||
|
|
|
@ -20,7 +20,7 @@ import Data.Aeson.TH (deriveJSON)
|
||||||
import qualified Data.Aeson.Types as JT
|
import qualified Data.Aeson.Types as JT
|
||||||
import Data.ByteString (ByteString)
|
import Data.ByteString (ByteString)
|
||||||
import Data.ByteString.Builder (Builder, word32BE, lazyByteString)
|
import Data.ByteString.Builder (Builder, word32BE, lazyByteString)
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as LB
|
||||||
import Data.String (fromString)
|
import Data.String (fromString)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Text.Encoding (decodeUtf8)
|
import Data.Text.Encoding (decodeUtf8)
|
||||||
|
@ -28,34 +28,35 @@ import Data.Word (Word32)
|
||||||
import qualified Network.HTTP.Types as N
|
import qualified Network.HTTP.Types as N
|
||||||
import qualified Network.HTTP2.Client as H
|
import qualified Network.HTTP2.Client as H
|
||||||
import Network.Transport.Internal (decodeWord32)
|
import Network.Transport.Internal (decodeWord32)
|
||||||
import Simplex.Chat.Controller (ChatResponse)
|
import Simplex.Chat.Controller
|
||||||
|
import Simplex.Chat.Remote.Transport
|
||||||
import Simplex.Chat.Remote.Types
|
import Simplex.Chat.Remote.Types
|
||||||
import Simplex.Messaging.Crypto.File (CryptoFile)
|
import Simplex.FileTransfer.Description (FileDigest (..))
|
||||||
|
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||||
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
|
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
|
||||||
import Simplex.Messaging.Transport.Buffer (getBuffered)
|
import Simplex.Messaging.Transport.Buffer (getBuffered)
|
||||||
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
|
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
|
||||||
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
|
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
|
||||||
import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile, hSendFile)
|
import Simplex.Messaging.Transport.HTTP2.File (hSendFile)
|
||||||
import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow, whenM)
|
import Simplex.Messaging.Util (liftEitherError, liftEitherWith, tshow)
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>), takeFileName)
|
||||||
import UnliftIO
|
import UnliftIO
|
||||||
import UnliftIO.Directory (doesFileExist, getFileSize)
|
|
||||||
|
|
||||||
data RemoteCommand
|
data RemoteCommand
|
||||||
= RCHello {deviceName :: Text}
|
= RCHello {deviceName :: Text}
|
||||||
| RCSend {command :: Text} -- TODO maybe ChatCommand here?
|
| RCSend {command :: Text} -- TODO maybe ChatCommand here?
|
||||||
| RCRecv {wait :: Int} -- this wait should be less than HTTP timeout
|
| RCRecv {wait :: Int} -- this wait should be less than HTTP timeout
|
||||||
| -- local file encryption is determined by the host, but can be overridden for videos
|
| -- local file encryption is determined by the host, but can be overridden for videos
|
||||||
RCStoreFile {fileSize :: Word32, encrypt :: Maybe Bool} -- requires attachment
|
RCStoreFile {fileName :: String, fileSize :: Word32, fileDigest :: FileDigest} -- requires attachment
|
||||||
| RCGetFile {filePath :: FilePath}
|
| RCGetFile {file :: RemoteFile}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
data RemoteResponse
|
data RemoteResponse
|
||||||
= RRHello {encoding :: PlatformEncoding, deviceName :: Text}
|
= RRHello {encoding :: PlatformEncoding, deviceName :: Text, encryptFiles :: Bool}
|
||||||
| RRChatResponse {chatResponse :: ChatResponse}
|
| RRChatResponse {chatResponse :: ChatResponse}
|
||||||
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout
|
| RRChatEvent {chatEvent :: Maybe ChatResponse} -- ^ 'Nothing' on poll timeout
|
||||||
| RRFileStored {fileSource :: CryptoFile}
|
| RRFileStored {filePath :: String}
|
||||||
| RRFile {fileSize :: Word32} -- provides attachment
|
| RRFile {fileSize :: Word32, fileDigest :: FileDigest} -- provides attachment , fileDigest :: FileDigest
|
||||||
| RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side
|
| RRProtocolError {remoteProcotolError :: RemoteProtocolError} -- ^ The protocol error happened on the server side
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
|
@ -67,14 +68,13 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse)
|
||||||
|
|
||||||
createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient
|
createRemoteHostClient :: HTTP2Client -> Text -> ExceptT RemoteProtocolError IO RemoteHostClient
|
||||||
createRemoteHostClient httpClient desktopName = do
|
createRemoteHostClient httpClient desktopName = do
|
||||||
logInfo "Sending initial hello"
|
logDebug "Sending initial hello"
|
||||||
(_getNext, rr) <- sendRemoteCommand httpClient localEncoding Nothing RCHello {deviceName = desktopName}
|
sendRemoteCommand' httpClient localEncoding Nothing RCHello {deviceName = desktopName} >>= \case
|
||||||
case rr of
|
RRHello {encoding, deviceName = mobileName, encryptFiles} -> do
|
||||||
rrh@RRHello {encoding, deviceName = mobileName} -> do
|
logDebug "Got initial hello"
|
||||||
logInfo $ "Got initial hello: " <> tshow rrh
|
|
||||||
when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding
|
when (encoding == PEKotlin && localEncoding == PESwift) $ throwError RPEIncompatibleEncoding
|
||||||
pure RemoteHostClient {remoteEncoding = encoding, remoteDeviceName = mobileName, httpClient}
|
pure RemoteHostClient {hostEncoding = encoding, hostDeviceName = mobileName, httpClient, encryptHostFiles = encryptFiles}
|
||||||
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
r -> badResponse r
|
||||||
|
|
||||||
closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m ()
|
closeRemoteHostClient :: MonadIO m => RemoteHostClient -> m ()
|
||||||
closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient
|
closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client httpClient
|
||||||
|
@ -82,48 +82,37 @@ closeRemoteHostClient RemoteHostClient {httpClient} = liftIO $ closeHTTP2Client
|
||||||
-- ** Commands
|
-- ** Commands
|
||||||
|
|
||||||
remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse
|
remoteSend :: RemoteHostClient -> ByteString -> ExceptT RemoteProtocolError IO ChatResponse
|
||||||
remoteSend RemoteHostClient {httpClient, remoteEncoding} cmd = do
|
remoteSend RemoteHostClient {httpClient, hostEncoding} cmd =
|
||||||
(_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCSend {command = decodeUtf8 cmd}
|
sendRemoteCommand' httpClient hostEncoding Nothing RCSend {command = decodeUtf8 cmd} >>= \case
|
||||||
case rr of
|
|
||||||
RRChatResponse cr -> pure cr
|
RRChatResponse cr -> pure cr
|
||||||
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
r -> badResponse r
|
||||||
|
|
||||||
remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse)
|
remoteRecv :: RemoteHostClient -> Int -> ExceptT RemoteProtocolError IO (Maybe ChatResponse)
|
||||||
remoteRecv RemoteHostClient {httpClient, remoteEncoding} ms = do
|
remoteRecv RemoteHostClient {httpClient, hostEncoding} ms =
|
||||||
(_getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCRecv {wait=ms}
|
sendRemoteCommand' httpClient hostEncoding Nothing RCRecv {wait = ms} >>= \case
|
||||||
case rr of
|
|
||||||
RRChatEvent cr_ -> pure cr_
|
RRChatEvent cr_ -> pure cr_
|
||||||
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
r -> badResponse r
|
||||||
|
|
||||||
remoteStoreFile :: RemoteHostClient -> FilePath -> Maybe Bool -> ExceptT RemoteProtocolError IO CryptoFile
|
remoteStoreFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath
|
||||||
remoteStoreFile RemoteHostClient {httpClient, remoteEncoding} localPath encrypt = do
|
remoteStoreFile RemoteHostClient {httpClient, hostEncoding} localPath fileName = do
|
||||||
(_getNext, rr) <- withFile localPath ReadMode $ \h -> do
|
(fileSize, fileDigest) <- getFileInfo localPath
|
||||||
fileSize' <- hFileSize h
|
let send h = sendRemoteCommand' httpClient hostEncoding (Just (h, fileSize)) RCStoreFile {fileName, fileSize, fileDigest}
|
||||||
when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileTooLarge
|
withFile localPath ReadMode send >>= \case
|
||||||
let fileSize = fromInteger fileSize'
|
RRFileStored {filePath = filePath'} -> pure filePath'
|
||||||
sendRemoteCommand httpClient remoteEncoding (Just (h, fileSize)) RCStoreFile {encrypt, fileSize}
|
r -> badResponse r
|
||||||
case rr of
|
|
||||||
RRFileStored {fileSource} -> pure fileSource
|
|
||||||
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
|
||||||
|
|
||||||
-- TODO this should work differently for CLI and UI clients
|
remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO ()
|
||||||
-- CLI - potentially, create new unique names and report them as created
|
remoteGetFile RemoteHostClient {httpClient, hostEncoding} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} =
|
||||||
-- UI - always use the same names and report error if file already exists
|
sendRemoteCommand httpClient hostEncoding Nothing RCGetFile {file = rf} >>= \case
|
||||||
-- alternatively, CLI should also use a fixed folder for remote session
|
(getChunk, RRFile {fileSize, fileDigest}) -> do
|
||||||
-- Possibly, path in the database should be optional and CLI commands should allow configuring it per session or use temp or download folder
|
-- TODO we could optimize by checking size and hash before receiving the file
|
||||||
remoteGetFile :: RemoteHostClient -> FilePath -> FilePath -> ExceptT RemoteProtocolError IO FilePath
|
let localPath = destDir </> takeFileName filePath
|
||||||
remoteGetFile RemoteHostClient {httpClient, remoteEncoding} baseDir filePath = do
|
receiveRemoteFile getChunk fileSize fileDigest localPath
|
||||||
(getNext, rr) <- sendRemoteCommand httpClient remoteEncoding Nothing RCGetFile {filePath}
|
(_, r) -> badResponse r
|
||||||
expectedSize <- case rr of
|
|
||||||
RRFile {fileSize} -> pure fileSize
|
-- TODO validate there is no attachment
|
||||||
_ -> throwError $ RPEUnexpectedResponse $ tshow rr
|
sendRemoteCommand' :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse
|
||||||
whenM (liftIO $ doesFileExist localFile) $ throwError RPEStoredFileExists
|
sendRemoteCommand' http remoteEncoding attachment_ rc = snd <$> sendRemoteCommand http remoteEncoding attachment_ rc
|
||||||
rc <- liftIO $ withFile localFile WriteMode $ \h -> hReceiveFile getNext h expectedSize
|
|
||||||
when (rc /= 0) $ throwError RPEInvalidSize
|
|
||||||
whenM ((== expectedSize) . fromIntegral <$> getFileSize localFile) $ throwError RPEInvalidSize
|
|
||||||
pure localFile
|
|
||||||
where
|
|
||||||
localFile = baseDir </> filePath
|
|
||||||
|
|
||||||
sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
|
sendRemoteCommand :: HTTP2Client -> PlatformEncoding -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
|
||||||
sendRemoteCommand http remoteEncoding attachment_ rc = do
|
sendRemoteCommand http remoteEncoding attachment_ rc = do
|
||||||
|
@ -139,6 +128,12 @@ sendRemoteCommand http remoteEncoding attachment_ rc = do
|
||||||
Just (h, sz) -> hSendFile h send sz
|
Just (h, sz) -> hSendFile h send sz
|
||||||
flush
|
flush
|
||||||
|
|
||||||
|
badResponse :: RemoteResponse -> ExceptT RemoteProtocolError IO a
|
||||||
|
badResponse = \case
|
||||||
|
RRProtocolError e -> throwError e
|
||||||
|
-- TODO handle chat errors?
|
||||||
|
r -> throwError $ RPEUnexpectedResponse $ tshow r
|
||||||
|
|
||||||
-- * Transport-level wrappers
|
-- * Transport-level wrappers
|
||||||
|
|
||||||
convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value
|
convertJSON :: PlatformEncoding -> PlatformEncoding -> J.Value -> J.Value
|
||||||
|
@ -183,7 +178,7 @@ pattern OwsfTag = (SingleFieldJSONTag, J.Bool True)
|
||||||
|
|
||||||
-- | Convert a command or a response into 'Builder'.
|
-- | Convert a command or a response into 'Builder'.
|
||||||
sizePrefixedEncode :: J.ToJSON a => a -> Builder
|
sizePrefixedEncode :: J.ToJSON a => a -> Builder
|
||||||
sizePrefixedEncode value = word32BE (fromIntegral $ BL.length json) <> lazyByteString json
|
sizePrefixedEncode value = word32BE (fromIntegral $ LB.length json) <> lazyByteString json
|
||||||
where
|
where
|
||||||
json = J.encode value
|
json = J.encode value
|
||||||
|
|
||||||
|
|
27
src/Simplex/Chat/Remote/Transport.hs
Normal file
27
src/Simplex/Chat/Remote/Transport.hs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
module Simplex.Chat.Remote.Transport where
|
||||||
|
|
||||||
|
import Control.Monad
|
||||||
|
import Control.Monad.Except
|
||||||
|
import Data.ByteString (ByteString)
|
||||||
|
import qualified Data.ByteString.Lazy as LB
|
||||||
|
import Data.Word (Word32)
|
||||||
|
import Simplex.FileTransfer.Description (FileDigest (..))
|
||||||
|
import Simplex.Chat.Remote.Types
|
||||||
|
import qualified Simplex.Messaging.Crypto.Lazy as LC
|
||||||
|
import Simplex.Messaging.Transport.HTTP2.File (hReceiveFile)
|
||||||
|
import UnliftIO
|
||||||
|
import UnliftIO.Directory (getFileSize)
|
||||||
|
|
||||||
|
receiveRemoteFile :: (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO ()
|
||||||
|
receiveRemoteFile getChunk fileSize fileDigest toPath = do
|
||||||
|
diff <- liftIO $ withFile toPath WriteMode $ \h -> hReceiveFile getChunk h fileSize
|
||||||
|
unless (diff == 0) $ throwError RPEFileSize
|
||||||
|
digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath
|
||||||
|
unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest
|
||||||
|
|
||||||
|
getFileInfo :: FilePath -> ExceptT RemoteProtocolError IO (Word32, FileDigest)
|
||||||
|
getFileInfo filePath = do
|
||||||
|
fileDigest <- liftIO $ FileDigest . LC.sha512Hash <$> LB.readFile filePath
|
||||||
|
fileSize' <- getFileSize filePath
|
||||||
|
when (fileSize' > toInteger (maxBound :: Word32)) $ throwError RPEFileSize
|
||||||
|
pure (fromInteger fileSize', fileDigest)
|
|
@ -10,14 +10,16 @@ import qualified Data.Aeson.TH as J
|
||||||
import Data.Int (Int64)
|
import Data.Int (Int64)
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
|
import Simplex.Messaging.Crypto.File (CryptoFile)
|
||||||
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
|
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
|
||||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
|
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
|
||||||
import UnliftIO
|
import UnliftIO
|
||||||
|
|
||||||
data RemoteHostClient = RemoteHostClient
|
data RemoteHostClient = RemoteHostClient
|
||||||
{ remoteEncoding :: PlatformEncoding,
|
{ hostEncoding :: PlatformEncoding,
|
||||||
remoteDeviceName :: Text,
|
hostDeviceName :: Text,
|
||||||
httpClient :: HTTP2Client
|
httpClient :: HTTP2Client,
|
||||||
|
encryptHostFiles :: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
data RemoteHostSession = RemoteHostSession
|
data RemoteHostSession = RemoteHostSession
|
||||||
|
@ -32,7 +34,8 @@ data RemoteProtocolError
|
||||||
| RPEIncompatibleEncoding
|
| RPEIncompatibleEncoding
|
||||||
| RPEUnexpectedFile
|
| RPEUnexpectedFile
|
||||||
| RPENoFile
|
| RPENoFile
|
||||||
| RPEFileTooLarge
|
| RPEFileSize
|
||||||
|
| RPEFileDigest
|
||||||
| RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent
|
| RPEUnexpectedResponse {response :: Text} -- ^ Wrong response received for the command sent
|
||||||
| RPEStoredFileExists -- ^ A file already exists in the destination position
|
| RPEStoredFileExists -- ^ A file already exists in the destination position
|
||||||
| RPEHTTP2 {http2Error :: Text}
|
| RPEHTTP2 {http2Error :: Text}
|
||||||
|
@ -87,7 +90,14 @@ data RemoteCtrlInfo = RemoteCtrlInfo
|
||||||
}
|
}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|
||||||
-- TODO: put into a proper place
|
data RemoteFile = RemoteFile
|
||||||
|
{ userId :: Int64,
|
||||||
|
fileId :: Int64,
|
||||||
|
sent :: Bool,
|
||||||
|
fileSource :: CryptoFile
|
||||||
|
}
|
||||||
|
deriving (Show)
|
||||||
|
|
||||||
data PlatformEncoding
|
data PlatformEncoding
|
||||||
= PESwift
|
= PESwift
|
||||||
| PEKotlin
|
| PEKotlin
|
||||||
|
@ -122,3 +132,5 @@ $(J.deriveJSON defaultJSON ''RemoteHostInfo)
|
||||||
$(J.deriveJSON defaultJSON ''RemoteCtrl)
|
$(J.deriveJSON defaultJSON ''RemoteCtrl)
|
||||||
|
|
||||||
$(J.deriveJSON defaultJSON ''RemoteCtrlInfo)
|
$(J.deriveJSON defaultJSON ''RemoteCtrlInfo)
|
||||||
|
|
||||||
|
$(J.deriveJSON defaultJSON ''RemoteFile)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
{-# LANGUAGE GADTs #-}
|
{-# LANGUAGE GADTs #-}
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
{-# LANGUAGE OverloadedRecordDot #-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE QuasiQuotes #-}
|
{-# LANGUAGE QuasiQuotes #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
@ -71,6 +72,7 @@ module Simplex.Chat.Store.Files
|
||||||
getSndFileTransfer,
|
getSndFileTransfer,
|
||||||
getSndFileTransfers,
|
getSndFileTransfers,
|
||||||
getContactFileInfo,
|
getContactFileInfo,
|
||||||
|
getLocalCryptoFile,
|
||||||
updateDirectCIFileStatus,
|
updateDirectCIFileStatus,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
@ -602,7 +604,10 @@ getRcvFileTransferById db fileId = do
|
||||||
(user,) <$> getRcvFileTransfer db user fileId
|
(user,) <$> getRcvFileTransfer db user fileId
|
||||||
|
|
||||||
getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer
|
getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer
|
||||||
getRcvFileTransfer db User {userId} fileId = do
|
getRcvFileTransfer db User {userId} = getRcvFileTransfer_ db userId
|
||||||
|
|
||||||
|
getRcvFileTransfer_ :: DB.Connection -> UserId -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer
|
||||||
|
getRcvFileTransfer_ db userId fileId = do
|
||||||
rftRow <-
|
rftRow <-
|
||||||
ExceptT . firstRow id (SERcvFileNotFound fileId) $
|
ExceptT . firstRow id (SERcvFileNotFound fileId) $
|
||||||
DB.query
|
DB.query
|
||||||
|
@ -808,25 +813,26 @@ getFileTransferProgress db user fileId = do
|
||||||
|
|
||||||
getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer
|
getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer
|
||||||
getFileTransfer db user@User {userId} fileId =
|
getFileTransfer db user@User {userId} fileId =
|
||||||
fileTransfer =<< liftIO getFileTransferRow
|
fileTransfer =<< liftIO (getFileTransferRow_ db userId fileId)
|
||||||
where
|
where
|
||||||
fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer
|
fileTransfer :: [(Maybe Int64, Maybe Int64)] -> ExceptT StoreError IO FileTransfer
|
||||||
fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId
|
fileTransfer [(Nothing, Just _)] = FTRcv <$> getRcvFileTransfer db user fileId
|
||||||
fileTransfer _ = do
|
fileTransfer _ = do
|
||||||
(ftm, fts) <- getSndFileTransfer db user fileId
|
(ftm, fts) <- getSndFileTransfer db user fileId
|
||||||
pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts}
|
pure $ FTSnd {fileTransferMeta = ftm, sndFileTransfers = fts}
|
||||||
getFileTransferRow :: IO [(Maybe Int64, Maybe Int64)]
|
|
||||||
getFileTransferRow =
|
getFileTransferRow_ :: DB.Connection -> UserId -> Int64 -> IO [(Maybe Int64, Maybe Int64)]
|
||||||
DB.query
|
getFileTransferRow_ db userId fileId =
|
||||||
db
|
DB.query
|
||||||
[sql|
|
db
|
||||||
SELECT s.file_id, r.file_id
|
[sql|
|
||||||
FROM files f
|
SELECT s.file_id, r.file_id
|
||||||
LEFT JOIN snd_files s ON s.file_id = f.file_id
|
FROM files f
|
||||||
LEFT JOIN rcv_files r ON r.file_id = f.file_id
|
LEFT JOIN snd_files s ON s.file_id = f.file_id
|
||||||
WHERE user_id = ? AND f.file_id = ?
|
LEFT JOIN rcv_files r ON r.file_id = f.file_id
|
||||||
|]
|
WHERE user_id = ? AND f.file_id = ?
|
||||||
(userId, fileId)
|
|]
|
||||||
|
(userId, fileId)
|
||||||
|
|
||||||
getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer])
|
getSndFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (FileTransferMeta, [SndFileTransfer])
|
||||||
getSndFileTransfer db user fileId = do
|
getSndFileTransfer db user fileId = do
|
||||||
|
@ -861,7 +867,10 @@ getSndFileTransfers_ db userId fileId =
|
||||||
Nothing -> Left $ SESndFileInvalid fileId
|
Nothing -> Left $ SESndFileInvalid fileId
|
||||||
|
|
||||||
getFileTransferMeta :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransferMeta
|
getFileTransferMeta :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransferMeta
|
||||||
getFileTransferMeta db User {userId} fileId =
|
getFileTransferMeta db User {userId} = getFileTransferMeta_ db userId
|
||||||
|
|
||||||
|
getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO FileTransferMeta
|
||||||
|
getFileTransferMeta_ db userId fileId =
|
||||||
ExceptT . firstRow fileTransferMeta (SEFileNotFound fileId) $
|
ExceptT . firstRow fileTransferMeta (SEFileNotFound fileId) $
|
||||||
DB.query
|
DB.query
|
||||||
db
|
db
|
||||||
|
@ -883,6 +892,20 @@ getContactFileInfo db User {userId} Contact {contactId} =
|
||||||
map toFileInfo
|
map toFileInfo
|
||||||
<$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId)
|
<$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId)
|
||||||
|
|
||||||
|
getLocalCryptoFile :: DB.Connection -> UserId -> Int64 -> Bool -> ExceptT StoreError IO CryptoFile
|
||||||
|
getLocalCryptoFile db userId fileId sent =
|
||||||
|
liftIO (getFileTransferRow_ db userId fileId) >>= \case
|
||||||
|
[(Nothing, Just _)] -> do
|
||||||
|
when sent $ throwError $ SEFileNotFound fileId
|
||||||
|
RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId
|
||||||
|
case fileStatus of
|
||||||
|
RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs
|
||||||
|
_ -> throwError $ SEFileNotFound fileId
|
||||||
|
_ -> do
|
||||||
|
unless sent $ throwError $ SEFileNotFound fileId
|
||||||
|
FileTransferMeta {filePath, xftpSndFile} <- getFileTransferMeta_ db userId fileId
|
||||||
|
pure $ CryptoFile filePath $ xftpSndFile >>= \f -> f.cryptoArgs
|
||||||
|
|
||||||
updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem
|
updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem
|
||||||
updateDirectCIFileStatus db user fileId fileStatus = do
|
updateDirectCIFileStatus db user fileId fileStatus = do
|
||||||
aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId
|
aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId
|
||||||
|
|
|
@ -14,6 +14,7 @@ module Simplex.Chat.View where
|
||||||
import qualified Data.Aeson as J
|
import qualified Data.Aeson as J
|
||||||
import qualified Data.Aeson.TH as JQ
|
import qualified Data.Aeson.TH as JQ
|
||||||
import qualified Data.ByteString.Char8 as B
|
import qualified Data.ByteString.Char8 as B
|
||||||
|
import Data.ByteString.Char8 (ByteString)
|
||||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
import Data.Char (isSpace, toUpper)
|
import Data.Char (isSpace, toUpper)
|
||||||
import Data.Function (on)
|
import Data.Function (on)
|
||||||
|
@ -76,7 +77,7 @@ serializeChatResponse :: (Maybe RemoteHostId, Maybe User) -> CurrentTime -> Time
|
||||||
serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_
|
serializeChatResponse user_ ts tz remoteHost_ = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz remoteHost_
|
||||||
|
|
||||||
responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString]
|
responseToView :: (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> Maybe RemoteHostId -> ChatResponse -> [StyledString]
|
||||||
responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case
|
responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showReceipts, testView} liveItems ts tz outputRH = \case
|
||||||
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
|
CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile
|
||||||
CRUsersList users -> viewUsersList users
|
CRUsersList users -> viewUsersList users
|
||||||
CRChatStarted -> ["chat started"]
|
CRChatStarted -> ["chat started"]
|
||||||
|
@ -185,10 +186,10 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei
|
||||||
CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c'
|
CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c'
|
||||||
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
|
CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct'
|
||||||
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
|
CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile
|
||||||
CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci
|
CRRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci
|
||||||
CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci
|
CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci
|
||||||
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
|
CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft
|
||||||
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e]
|
CRRcvFileError u ci e -> ttyUser u $ receivingFile_' hu testView "error" ci <> [sShow e]
|
||||||
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
|
CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft
|
||||||
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
|
CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft
|
||||||
CRSndFileStartXFTP {} -> []
|
CRSndFileStartXFTP {} -> []
|
||||||
|
@ -272,6 +273,9 @@ responseToView (currentRH, user_) ChatConfig {logLevel, showReactions, showRecei
|
||||||
CRRemoteHostList hs -> viewRemoteHosts hs
|
CRRemoteHostList hs -> viewRemoteHosts hs
|
||||||
CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"]
|
CRRemoteHostConnected RemoteHostInfo {remoteHostId = rhId} -> ["remote host " <> sShow rhId <> " connected"]
|
||||||
CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"]
|
CRRemoteHostStopped rhId -> ["remote host " <> sShow rhId <> " stopped"]
|
||||||
|
CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) ->
|
||||||
|
[plain $ "file " <> filePath <> " stored on remote host " <> show rhId]
|
||||||
|
<> maybe [] ((: []) . plain . cryptoFileArgsStr testView) cfArgs_
|
||||||
CRRemoteCtrlList cs -> viewRemoteCtrls cs
|
CRRemoteCtrlList cs -> viewRemoteCtrls cs
|
||||||
CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"]
|
CRRemoteCtrlRegistered RemoteCtrlInfo {remoteCtrlId = rcId} -> ["remote controller " <> sShow rcId <> " registered"]
|
||||||
CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint]
|
CRRemoteCtrlAnnounce fingerprint -> ["remote controller announced", "connection code:", plain $ strEncode fingerprint]
|
||||||
|
@ -1493,18 +1497,25 @@ savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource
|
||||||
["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath]
|
["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath]
|
||||||
savingFile' _ = ["saving file"] -- shouldn't happen
|
savingFile' _ = ["saving file"] -- shouldn't happen
|
||||||
|
|
||||||
receivingFile_' :: Bool -> String -> AChatItem -> [StyledString]
|
receivingFile_' :: (Maybe RemoteHostId, Maybe User) -> Bool -> String -> AChatItem -> [StyledString]
|
||||||
receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) =
|
receivingFile_' hu testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just f@(CryptoFile _ cfArgs_)}, chatDir}) =
|
||||||
[plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_
|
[plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ <> getRemoteFileStr
|
||||||
where
|
where
|
||||||
cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"]
|
cfArgsStr (Just cfArgs) = [plain (cryptoFileArgsStr testView cfArgs) | status == "completed"]
|
||||||
where
|
|
||||||
s =
|
|
||||||
if testView
|
|
||||||
then LB.toStrict $ J.encode cfArgs
|
|
||||||
else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce
|
|
||||||
cfArgsStr _ = []
|
cfArgsStr _ = []
|
||||||
receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen
|
getRemoteFileStr = case hu of
|
||||||
|
(Just rhId, Just User {userId}) | status == "completed" ->
|
||||||
|
[ "File received to connected remote host " <> sShow rhId,
|
||||||
|
"To download to this device use:",
|
||||||
|
highlight ("/get remote file " <> show rhId <> " " <> LB.unpack (J.encode RemoteFile {userId, fileId, sent = False, fileSource = f}))
|
||||||
|
]
|
||||||
|
_ -> []
|
||||||
|
receivingFile_' _ _ status _ = [plain status <> " receiving file"] -- shouldn't happen
|
||||||
|
|
||||||
|
cryptoFileArgsStr :: Bool -> CryptoFileArgs -> ByteString
|
||||||
|
cryptoFileArgsStr testView cfArgs@(CFArgs key nonce)
|
||||||
|
| testView = LB.toStrict $ J.encode cfArgs
|
||||||
|
| otherwise = "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce
|
||||||
|
|
||||||
fileFrom :: ChatInfo c -> CIDirection c d -> StyledString
|
fileFrom :: ChatInfo c -> CIDirection c d -> StyledString
|
||||||
fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct
|
fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct
|
||||||
|
@ -1818,8 +1829,8 @@ viewChatError logLevel = \case
|
||||||
Nothing -> ""
|
Nothing -> ""
|
||||||
cId :: Connection -> StyledString
|
cId :: Connection -> StyledString
|
||||||
cId conn = sShow conn.connId
|
cId conn = sShow conn.connId
|
||||||
ChatErrorRemoteCtrl todo'rc -> [sShow todo'rc]
|
ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e]
|
||||||
ChatErrorRemoteHost remoteHostId todo'rh -> [sShow remoteHostId, sShow todo'rh]
|
ChatErrorRemoteHost rhId e -> [plain $ "remote host " <> show rhId <> " error: " <> show e]
|
||||||
where
|
where
|
||||||
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
|
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
|
||||||
sqliteError' = \case
|
sqliteError' = \case
|
||||||
|
|
|
@ -9,7 +9,9 @@ import ChatClient
|
||||||
import ChatTests.Utils
|
import ChatTests.Utils
|
||||||
import Control.Logger.Simple
|
import Control.Logger.Simple
|
||||||
import Control.Monad
|
import Control.Monad
|
||||||
|
import qualified Data.Aeson as J
|
||||||
import qualified Data.ByteString as B
|
import qualified Data.ByteString as B
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||||
import Data.List.NonEmpty (NonEmpty (..))
|
import Data.List.NonEmpty (NonEmpty (..))
|
||||||
import qualified Data.Map.Strict as M
|
import qualified Data.Map.Strict as M
|
||||||
import Network.HTTP.Types (ok200)
|
import Network.HTTP.Types (ok200)
|
||||||
|
@ -17,10 +19,14 @@ import qualified Network.HTTP2.Client as C
|
||||||
import qualified Network.HTTP2.Server as S
|
import qualified Network.HTTP2.Server as S
|
||||||
import qualified Network.Socket as N
|
import qualified Network.Socket as N
|
||||||
import qualified Network.TLS as TLS
|
import qualified Network.TLS as TLS
|
||||||
|
import Simplex.Chat.Archive (archiveFilesFolder)
|
||||||
|
import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..))
|
||||||
import qualified Simplex.Chat.Controller as Controller
|
import qualified Simplex.Chat.Controller as Controller
|
||||||
|
import Simplex.Chat.Mobile.File
|
||||||
import Simplex.Chat.Remote.Types
|
import Simplex.Chat.Remote.Types
|
||||||
import qualified Simplex.Chat.Remote.Discovery as Discovery
|
import qualified Simplex.Chat.Remote.Discovery as Discovery
|
||||||
import qualified Simplex.Messaging.Crypto as C
|
import qualified Simplex.Messaging.Crypto as C
|
||||||
|
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
|
||||||
import Simplex.Messaging.Encoding.String
|
import Simplex.Messaging.Encoding.String
|
||||||
import qualified Simplex.Messaging.Transport as Transport
|
import qualified Simplex.Messaging.Transport as Transport
|
||||||
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
||||||
|
@ -28,7 +34,7 @@ import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials)
|
||||||
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest)
|
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Response (..), closeHTTP2Client, sendRequest)
|
||||||
import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..))
|
import Simplex.Messaging.Transport.HTTP2.Server (HTTP2Request (..))
|
||||||
import Simplex.Messaging.Util
|
import Simplex.Messaging.Util
|
||||||
import System.FilePath (makeRelative, (</>))
|
import System.FilePath ((</>))
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import UnliftIO
|
import UnliftIO
|
||||||
import UnliftIO.Concurrent
|
import UnliftIO.Concurrent
|
||||||
|
@ -41,7 +47,9 @@ remoteTests = describe "Remote" $ do
|
||||||
it "performs protocol handshake" remoteHandshakeTest
|
it "performs protocol handshake" remoteHandshakeTest
|
||||||
it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check
|
it "performs protocol handshake (again)" remoteHandshakeTest -- leaking servers regression check
|
||||||
it "sends messages" remoteMessageTest
|
it "sends messages" remoteMessageTest
|
||||||
xit "sends files" remoteFileTest
|
describe "remote files" $ do
|
||||||
|
it "store/get/send/receive files" remoteStoreFileTest
|
||||||
|
it "should sends files from CLI wihtout /store" remoteCLIFileTest
|
||||||
|
|
||||||
-- * Low-level TLS with ephemeral credentials
|
-- * Low-level TLS with ephemeral credentials
|
||||||
|
|
||||||
|
@ -159,32 +167,158 @@ remoteMessageTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob
|
||||||
threadDelay 1000000
|
threadDelay 1000000
|
||||||
logNote "done"
|
logNote "done"
|
||||||
|
|
||||||
remoteFileTest :: (HasCallStack) => FilePath -> IO ()
|
remoteStoreFileTest :: HasCallStack => FilePath -> IO ()
|
||||||
remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> do
|
remoteStoreFileTest =
|
||||||
|
testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob ->
|
||||||
|
withXFTPServer $ do
|
||||||
|
let mobileFiles = "./tests/tmp/mobile_files"
|
||||||
|
mobile ##> ("/_files_folder " <> mobileFiles)
|
||||||
|
mobile <## "ok"
|
||||||
|
let desktopFiles = "./tests/tmp/desktop_files"
|
||||||
|
desktop ##> ("/_files_folder " <> desktopFiles)
|
||||||
|
desktop <## "ok"
|
||||||
|
let desktopHostFiles = "./tests/tmp/remote_hosts_data"
|
||||||
|
desktop ##> ("/remote_hosts_folder " <> desktopHostFiles)
|
||||||
|
desktop <## "ok"
|
||||||
|
let bobFiles = "./tests/tmp/bob_files"
|
||||||
|
bob ##> ("/_files_folder " <> bobFiles)
|
||||||
|
bob <## "ok"
|
||||||
|
startRemote mobile desktop
|
||||||
|
contactBob desktop bob
|
||||||
|
rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop)
|
||||||
|
desktopHostStore <- case M.lookup 1 rhs of
|
||||||
|
Just RemoteHostSession {storePath} -> pure $ desktopHostFiles </> storePath </> archiveFilesFolder
|
||||||
|
_ -> fail "Host session 1 should be started"
|
||||||
|
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
|
||||||
|
desktop <## "file test.pdf stored on remote host 1"
|
||||||
|
src <- B.readFile "tests/fixtures/test.pdf"
|
||||||
|
B.readFile (mobileFiles </> "test.pdf") `shouldReturn` src
|
||||||
|
B.readFile (desktopHostStore </> "test.pdf") `shouldReturn` src
|
||||||
|
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
|
||||||
|
desktop <## "file test_1.pdf stored on remote host 1"
|
||||||
|
B.readFile (mobileFiles </> "test_1.pdf") `shouldReturn` src
|
||||||
|
B.readFile (desktopHostStore </> "test_1.pdf") `shouldReturn` src
|
||||||
|
desktop ##> "/store remote file 1 encrypt=on tests/fixtures/test.pdf"
|
||||||
|
desktop <## "file test_2.pdf stored on remote host 1"
|
||||||
|
Just cfArgs@(CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine desktop
|
||||||
|
chatReadFile (mobileFiles </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
|
||||||
|
chatReadFile (desktopHostStore </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
|
||||||
|
|
||||||
|
removeFile (desktopHostStore </> "test_1.pdf")
|
||||||
|
removeFile (desktopHostStore </> "test_2.pdf")
|
||||||
|
|
||||||
|
-- cannot get file before it is used
|
||||||
|
desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}"
|
||||||
|
hostError desktop "SEFileNotFound"
|
||||||
|
-- send file not encrypted locally on mobile host
|
||||||
|
desktop ##> "/_send @2 json {\"filePath\": \"test_1.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"sending a file\"}}"
|
||||||
|
desktop <# "@bob sending a file"
|
||||||
|
desktop <# "/f @bob test_1.pdf"
|
||||||
|
desktop <## "use /fc 1 to cancel sending"
|
||||||
|
bob <# "alice> sending a file"
|
||||||
|
bob <# "alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)"
|
||||||
|
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||||
|
bob ##> "/fr 1"
|
||||||
|
concurrentlyN_
|
||||||
|
[ do
|
||||||
|
desktop <## "completed uploading file 1 (test_1.pdf) for bob",
|
||||||
|
do
|
||||||
|
bob <## "saving file 1 from alice to test_1.pdf"
|
||||||
|
bob <## "started receiving file 1 (test_1.pdf) from alice"
|
||||||
|
bob <## "completed receiving file 1 (test_1.pdf) from alice"
|
||||||
|
]
|
||||||
|
B.readFile (bobFiles </> "test_1.pdf") `shouldReturn` src
|
||||||
|
-- returns error for inactive user
|
||||||
|
desktop ##> "/get remote file 1 {\"userId\": 2, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}"
|
||||||
|
hostError desktop "CEDifferentActiveUser"
|
||||||
|
-- returns error with incorrect file ID
|
||||||
|
desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 2, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}"
|
||||||
|
hostError desktop "SEFileNotFound"
|
||||||
|
-- gets file
|
||||||
|
doesFileExist (desktopHostStore </> "test_1.pdf") `shouldReturn` False
|
||||||
|
desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 1, \"sent\": true, \"fileSource\": {\"filePath\": \"test_1.pdf\"}}"
|
||||||
|
desktop <## "ok"
|
||||||
|
B.readFile (desktopHostStore </> "test_1.pdf") `shouldReturn` src
|
||||||
|
|
||||||
|
-- send file encrypted locally on mobile host
|
||||||
|
desktop ##> ("/_send @2 json {\"fileSource\": {\"filePath\":\"test_2.pdf\", \"cryptoArgs\": " <> LB.unpack (J.encode cfArgs) <> "}, \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}")
|
||||||
|
desktop <# "/f @bob test_2.pdf"
|
||||||
|
desktop <## "use /fc 2 to cancel sending"
|
||||||
|
bob <# "alice> sends file test_2.pdf (266.0 KiB / 272376 bytes)"
|
||||||
|
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
|
||||||
|
bob ##> "/fr 2"
|
||||||
|
concurrentlyN_
|
||||||
|
[ do
|
||||||
|
desktop <## "completed uploading file 2 (test_2.pdf) for bob",
|
||||||
|
do
|
||||||
|
bob <## "saving file 2 from alice to test_2.pdf"
|
||||||
|
bob <## "started receiving file 2 (test_2.pdf) from alice"
|
||||||
|
bob <## "completed receiving file 2 (test_2.pdf) from alice"
|
||||||
|
]
|
||||||
|
B.readFile (bobFiles </> "test_2.pdf") `shouldReturn` src
|
||||||
|
|
||||||
|
-- receive file via remote host
|
||||||
|
copyFile "./tests/fixtures/test.jpg" (bobFiles </> "test.jpg")
|
||||||
|
bob #> "/f @alice test.jpg"
|
||||||
|
bob <## "use /fc 3 to cancel sending"
|
||||||
|
desktop <# "bob> sends file test.jpg (136.5 KiB / 139737 bytes)"
|
||||||
|
desktop <## "use /fr 3 [<dir>/ | <path>] to receive it"
|
||||||
|
desktop ##> "/fr 3 encrypt=on"
|
||||||
|
concurrentlyN_
|
||||||
|
[ do
|
||||||
|
bob <## "completed uploading file 3 (test.jpg) for alice",
|
||||||
|
do
|
||||||
|
desktop <## "saving file 3 from bob to test.jpg"
|
||||||
|
desktop <## "started receiving file 3 (test.jpg) from bob"
|
||||||
|
desktop <## "completed receiving file 3 (test.jpg) from bob"
|
||||||
|
]
|
||||||
|
Just cfArgs'@(CFArgs key' nonce') <- J.decode . LB.pack <$> getTermLine desktop
|
||||||
|
desktop <## "File received to connected remote host 1"
|
||||||
|
desktop <## "To download to this device use:"
|
||||||
|
getCmd <- getTermLine desktop
|
||||||
|
getCmd `shouldBe` ("/get remote file 1 {\"userId\":1,\"fileId\":3,\"sent\":false,\"fileSource\":{\"filePath\":\"test.jpg\",\"cryptoArgs\":" <> LB.unpack (J.encode cfArgs') <> "}}")
|
||||||
|
src' <- B.readFile (bobFiles </> "test.jpg")
|
||||||
|
chatReadFile (mobileFiles </> "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src')
|
||||||
|
doesFileExist (desktopHostStore </> "test.jpg") `shouldReturn` False
|
||||||
|
-- returns error with incorrect key
|
||||||
|
desktop ##> "/get remote file 1 {\"userId\": 1, \"fileId\": 3, \"sent\": false, \"fileSource\": {\"filePath\": \"test.jpg\", \"cryptoArgs\": null}}"
|
||||||
|
hostError desktop "SEFileNotFound"
|
||||||
|
doesFileExist (desktopHostStore </> "test.jpg") `shouldReturn` False
|
||||||
|
desktop ##> getCmd
|
||||||
|
desktop <## "ok"
|
||||||
|
chatReadFile (desktopHostStore </> "test.jpg") (strEncode key') (strEncode nonce') `shouldReturn` Right (LB.fromStrict src')
|
||||||
|
|
||||||
|
stopMobile mobile desktop
|
||||||
|
where
|
||||||
|
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"}
|
||||||
|
hostError cc err = do
|
||||||
|
r <- getTermLine cc
|
||||||
|
r `shouldStartWith` "remote host 1 error"
|
||||||
|
r `shouldContain` err
|
||||||
|
|
||||||
|
remoteCLIFileTest :: (HasCallStack) => FilePath -> IO ()
|
||||||
|
remoteCLIFileTest = testChatCfg3 cfg aliceProfile aliceDesktopProfile bobProfile $ \mobile desktop bob -> withXFTPServer $ do
|
||||||
|
createDirectoryIfMissing True "./tests/tmp/tmp/"
|
||||||
let mobileFiles = "./tests/tmp/mobile_files"
|
let mobileFiles = "./tests/tmp/mobile_files"
|
||||||
mobile ##> ("/_files_folder " <> mobileFiles)
|
mobile ##> ("/_files_folder " <> mobileFiles)
|
||||||
mobile <## "ok"
|
mobile <## "ok"
|
||||||
let desktopFiles = "./tests/tmp/desktop_files"
|
let bobFiles = "./tests/tmp/bob_files/"
|
||||||
desktop ##> ("/_files_folder " <> desktopFiles)
|
createDirectoryIfMissing True bobFiles
|
||||||
|
let desktopHostFiles = "./tests/tmp/remote_hosts_data"
|
||||||
|
desktop ##> ("/remote_hosts_folder " <> desktopHostFiles)
|
||||||
desktop <## "ok"
|
desktop <## "ok"
|
||||||
let bobFiles = "./tests/tmp/bob_files"
|
|
||||||
bob ##> ("/_files_folder " <> bobFiles)
|
|
||||||
bob <## "ok"
|
|
||||||
|
|
||||||
startRemote mobile desktop
|
startRemote mobile desktop
|
||||||
contactBob desktop bob
|
contactBob desktop bob
|
||||||
|
|
||||||
rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop)
|
rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop)
|
||||||
desktopStore <- case M.lookup 1 rhs of
|
desktopHostStore <- case M.lookup 1 rhs of
|
||||||
Just RemoteHostSession {storePath} -> pure storePath
|
Just RemoteHostSession {storePath} -> pure $ desktopHostFiles </> storePath </> archiveFilesFolder
|
||||||
_ -> fail "Host session 1 should be started"
|
_ -> fail "Host session 1 should be started"
|
||||||
|
|
||||||
doesFileExist "./tests/tmp/mobile_files/test.pdf" `shouldReturn` False
|
|
||||||
doesFileExist (desktopFiles </> desktopStore </> "test.pdf") `shouldReturn` False
|
|
||||||
mobileName <- userName mobile
|
mobileName <- userName mobile
|
||||||
|
|
||||||
bobsFile <- makeRelative bobFiles <$> makeAbsolute "tests/fixtures/test.pdf"
|
bob #> ("/f @" <> mobileName <> " " <> "tests/fixtures/test.pdf")
|
||||||
bob #> ("/f @" <> mobileName <> " " <> bobsFile)
|
|
||||||
bob <## "use /fc 1 to cancel sending"
|
bob <## "use /fc 1 to cancel sending"
|
||||||
|
|
||||||
desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
desktop <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||||
|
@ -192,63 +326,47 @@ remoteFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mobile
|
||||||
desktop ##> "/fr 1"
|
desktop ##> "/fr 1"
|
||||||
concurrentlyN_
|
concurrentlyN_
|
||||||
[ do
|
[ do
|
||||||
bob <## "started sending file 1 (test.pdf) to alice"
|
bob <## "completed uploading file 1 (test.pdf) for alice",
|
||||||
bob <## "completed sending file 1 (test.pdf) to alice",
|
|
||||||
do
|
do
|
||||||
desktop <## "saving file 1 from bob to test.pdf"
|
desktop <## "saving file 1 from bob to test.pdf"
|
||||||
desktop <## "started receiving file 1 (test.pdf) from bob"
|
desktop <## "started receiving file 1 (test.pdf) from bob"
|
||||||
|
desktop <## "completed receiving file 1 (test.pdf) from bob"
|
||||||
]
|
]
|
||||||
let desktopReceived = desktopFiles </> desktopStore </> "test.pdf"
|
|
||||||
-- desktop <## ("completed receiving file 1 (" <> desktopReceived <> ") from bob")
|
|
||||||
desktop <## "completed receiving file 1 (test.pdf) from bob"
|
|
||||||
bobsFileSize <- getFileSize bobsFile
|
|
||||||
-- getFileSize desktopReceived `shouldReturn` bobsFileSize
|
|
||||||
bobsFileBytes <- B.readFile bobsFile
|
|
||||||
-- B.readFile desktopReceived `shouldReturn` bobsFileBytes
|
|
||||||
|
|
||||||
-- test file transit on mobile
|
desktop <## "File received to connected remote host 1"
|
||||||
mobile ##> "/fs 1"
|
desktop <## "To download to this device use:"
|
||||||
mobile <## "receiving file 1 (test.pdf) complete, path: test.pdf"
|
getCmd <- getTermLine desktop
|
||||||
getFileSize (mobileFiles </> "test.pdf") `shouldReturn` bobsFileSize
|
src <- B.readFile "tests/fixtures/test.pdf"
|
||||||
B.readFile (mobileFiles </> "test.pdf") `shouldReturn` bobsFileBytes
|
B.readFile (mobileFiles </> "test.pdf") `shouldReturn` src
|
||||||
|
doesFileExist (desktopHostStore </> "test.pdf") `shouldReturn` False
|
||||||
|
desktop ##> getCmd
|
||||||
|
desktop <## "ok"
|
||||||
|
B.readFile (desktopHostStore </> "test.pdf") `shouldReturn` src
|
||||||
|
|
||||||
logNote "file received"
|
desktop `send` "/f @bob tests/fixtures/test.jpg"
|
||||||
|
desktop <# "/f @bob test.jpg"
|
||||||
desktopFile <- makeRelative desktopFiles <$> makeAbsolute "tests/fixtures/logo.jpg" -- XXX: not necessary for _send, but required for /f
|
|
||||||
logNote $ "sending " <> tshow desktopFile
|
|
||||||
doesFileExist (bobFiles </> "logo.jpg") `shouldReturn` False
|
|
||||||
doesFileExist (mobileFiles </> "logo.jpg") `shouldReturn` False
|
|
||||||
desktop ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/logo.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi, sending a file\"}}"
|
|
||||||
desktop <# "@bob hi, sending a file"
|
|
||||||
desktop <# "/f @bob logo.jpg"
|
|
||||||
desktop <## "use /fc 2 to cancel sending"
|
desktop <## "use /fc 2 to cancel sending"
|
||||||
|
|
||||||
bob <# "alice> hi, sending a file"
|
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
|
||||||
bob <# "alice> sends file logo.jpg (31.3 KiB / 32080 bytes)"
|
|
||||||
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
|
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
|
||||||
bob ##> "/fr 2"
|
bob ##> ("/fr 2 " <> bobFiles)
|
||||||
concurrentlyN_
|
concurrentlyN_
|
||||||
[ do
|
[ do
|
||||||
bob <## "saving file 2 from alice to logo.jpg"
|
desktop <## "completed uploading file 2 (test.jpg) for bob",
|
||||||
bob <## "started receiving file 2 (logo.jpg) from alice"
|
|
||||||
bob <## "completed receiving file 2 (logo.jpg) from alice"
|
|
||||||
bob ##> "/fs 2"
|
|
||||||
bob <## "receiving file 2 (logo.jpg) complete, path: logo.jpg",
|
|
||||||
do
|
do
|
||||||
desktop <## "started sending file 2 (logo.jpg) to bob"
|
bob <## "saving file 2 from alice to ./tests/tmp/bob_files/test.jpg"
|
||||||
desktop <## "completed sending file 2 (logo.jpg) to bob"
|
bob <## "started receiving file 2 (test.jpg) from alice"
|
||||||
|
bob <## "completed receiving file 2 (test.jpg) from alice"
|
||||||
]
|
]
|
||||||
desktopFileSize <- getFileSize desktopFile
|
|
||||||
getFileSize (bobFiles </> "logo.jpg") `shouldReturn` desktopFileSize
|
|
||||||
getFileSize (mobileFiles </> "logo.jpg") `shouldReturn` desktopFileSize
|
|
||||||
|
|
||||||
desktopFileBytes <- B.readFile desktopFile
|
src' <- B.readFile "tests/fixtures/test.jpg"
|
||||||
B.readFile (bobFiles </> "logo.jpg") `shouldReturn` desktopFileBytes
|
B.readFile (mobileFiles </> "test.jpg") `shouldReturn` src'
|
||||||
B.readFile (mobileFiles </> "logo.jpg") `shouldReturn` desktopFileBytes
|
B.readFile (desktopHostStore </> "test.jpg") `shouldReturn` src'
|
||||||
|
B.readFile (bobFiles </> "test.jpg") `shouldReturn` src'
|
||||||
logNote "file sent"
|
|
||||||
|
|
||||||
stopMobile mobile desktop
|
stopMobile mobile desktop
|
||||||
|
where
|
||||||
|
cfg = testCfg {xftpFileConfig = Just $ XFTPFileConfig {minFileSize = 0}, tempDir = Just "./tests/tmp/tmp"}
|
||||||
|
|
||||||
-- * Utils
|
-- * Utils
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue