2023-10-11 11:45:05 +03:00
|
|
|
{-# LANGUAGE DataKinds #-}
|
2023-09-27 11:41:02 +03:00
|
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
|
|
{-# LANGUAGE FlexibleContexts #-}
|
|
|
|
{-# LANGUAGE LambdaCase #-}
|
|
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
2023-10-11 19:11:01 +01:00
|
|
|
{-# LANGUAGE PatternSynonyms #-}
|
2023-09-27 11:41:02 +03:00
|
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
2023-10-07 16:23:24 +03:00
|
|
|
{-# LANGUAGE TupleSections #-}
|
2023-09-27 11:41:02 +03:00
|
|
|
|
|
|
|
module Simplex.Chat.Remote where
|
|
|
|
|
2023-10-07 16:23:24 +03:00
|
|
|
import Control.Logger.Simple
|
2023-10-04 18:36:10 +03:00
|
|
|
import Control.Monad
|
2023-09-27 11:41:02 +03:00
|
|
|
import Control.Monad.Except
|
|
|
|
import Control.Monad.IO.Class
|
2023-10-07 16:23:24 +03:00
|
|
|
import Control.Monad.Reader (asks)
|
2023-10-04 18:36:10 +03:00
|
|
|
import Control.Monad.STM (retry)
|
|
|
|
import Crypto.Random (getRandomBytes)
|
2023-10-11 19:11:01 +01:00
|
|
|
import Data.Aeson ((.=))
|
2023-09-27 11:41:02 +03:00
|
|
|
import qualified Data.Aeson as J
|
2023-10-11 19:11:01 +01:00
|
|
|
import qualified Data.Aeson.Key as JK
|
|
|
|
import qualified Data.Aeson.KeyMap as JM
|
2023-10-11 11:45:05 +03:00
|
|
|
import qualified Data.Attoparsec.ByteString.Char8 as A
|
2023-09-27 18:24:38 +03:00
|
|
|
import qualified Data.Binary.Builder as Binary
|
2023-10-11 11:45:05 +03:00
|
|
|
import Data.ByteString (ByteString, hPut)
|
2023-10-04 18:36:10 +03:00
|
|
|
import qualified Data.ByteString.Base64.URL as B64U
|
|
|
|
import qualified Data.ByteString.Char8 as B
|
2023-10-11 11:45:05 +03:00
|
|
|
import Data.Int (Int64)
|
2023-10-04 18:36:10 +03:00
|
|
|
import Data.List.NonEmpty (NonEmpty (..))
|
2023-09-27 11:41:02 +03:00
|
|
|
import qualified Data.Map.Strict as M
|
2023-10-11 11:45:05 +03:00
|
|
|
import Data.Maybe (fromMaybe)
|
2023-10-07 16:23:24 +03:00
|
|
|
import qualified Data.Text as T
|
2023-10-11 11:45:05 +03:00
|
|
|
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
2023-09-27 11:41:02 +03:00
|
|
|
import qualified Network.HTTP.Types as HTTP
|
2023-10-07 16:23:24 +03:00
|
|
|
import qualified Network.HTTP.Types.Status as Status
|
2023-09-27 11:41:02 +03:00
|
|
|
import qualified Network.HTTP2.Client as HTTP2Client
|
2023-10-07 16:23:24 +03:00
|
|
|
import qualified Network.HTTP2.Server as HTTP2Server
|
2023-09-29 14:56:56 +03:00
|
|
|
import Network.Socket (SockAddr (..), hostAddressToTuple)
|
2023-09-27 11:41:02 +03:00
|
|
|
import Simplex.Chat.Controller
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.Chat.Messages (AChatItem (..), CIFile (..), CIFileStatus (..), ChatItem (..), chatNameStr)
|
|
|
|
import Simplex.Chat.Messages.CIContent (MsgDirection (..), SMsgDirection (..))
|
2023-09-29 14:56:56 +03:00
|
|
|
import qualified Simplex.Chat.Remote.Discovery as Discovery
|
2023-09-27 11:41:02 +03:00
|
|
|
import Simplex.Chat.Remote.Types
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.Chat.Store.Files (getRcvFileTransfer)
|
|
|
|
import Simplex.Chat.Store.Profiles (getUser)
|
2023-09-29 14:56:56 +03:00
|
|
|
import Simplex.Chat.Store.Remote
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.Chat.Store.Shared (StoreError (..))
|
2023-09-27 11:41:02 +03:00
|
|
|
import Simplex.Chat.Types
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.FileTransfer.Util (uniqueCombine)
|
2023-09-29 14:56:56 +03:00
|
|
|
import qualified Simplex.Messaging.Crypto as C
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
2023-09-29 14:56:56 +03:00
|
|
|
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
2023-10-11 19:11:01 +01:00
|
|
|
import Simplex.Messaging.Parsers (pattern SingleFieldJSONTag, pattern TaggedObjectJSONTag, pattern TaggedObjectJSONData)
|
2023-09-29 14:56:56 +03:00
|
|
|
import qualified Simplex.Messaging.TMap as TM
|
|
|
|
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
2023-10-04 18:36:10 +03:00
|
|
|
import Simplex.Messaging.Transport.Credentials (genCredentials, tlsCredentials)
|
2023-10-11 11:45:05 +03:00
|
|
|
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), defaultHTTP2BufferSize)
|
2023-10-04 18:36:10 +03:00
|
|
|
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
|
2023-09-27 11:41:02 +03:00
|
|
|
import qualified Simplex.Messaging.Transport.HTTP2.Client as HTTP2
|
2023-09-27 18:24:38 +03:00
|
|
|
import qualified Simplex.Messaging.Transport.HTTP2.Server as HTTP2
|
2023-10-07 16:23:24 +03:00
|
|
|
import Simplex.Messaging.Util (bshow, ifM, tshow)
|
2023-10-11 11:45:05 +03:00
|
|
|
import System.FilePath (isPathSeparator, takeFileName, (</>))
|
2023-09-29 14:56:56 +03:00
|
|
|
import UnliftIO
|
2023-10-11 11:45:05 +03:00
|
|
|
import UnliftIO.Directory (createDirectoryIfMissing, getFileSize, makeAbsolute)
|
2023-09-27 11:41:02 +03:00
|
|
|
|
2023-09-27 18:24:38 +03:00
|
|
|
withRemoteHostSession :: (ChatMonad m) => RemoteHostId -> (RemoteHostSession -> m a) -> m a
|
2023-09-27 11:41:02 +03:00
|
|
|
withRemoteHostSession remoteHostId action = do
|
|
|
|
chatReadVar remoteHostSessions >>= maybe err action . M.lookup remoteHostId
|
|
|
|
where
|
|
|
|
err = throwError $ ChatErrorRemoteHost remoteHostId RHMissing
|
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
withRemoteHost :: (ChatMonad m) => RemoteHostId -> (RemoteHost -> m a) -> m a
|
|
|
|
withRemoteHost remoteHostId action =
|
|
|
|
withStore' (`getRemoteHost` remoteHostId) >>= \case
|
|
|
|
Nothing -> throwError $ ChatErrorRemoteHost remoteHostId RHMissing
|
|
|
|
Just rh -> action rh
|
|
|
|
|
2023-09-29 14:56:56 +03:00
|
|
|
startRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse
|
|
|
|
startRemoteHost remoteHostId = do
|
2023-10-07 16:23:24 +03:00
|
|
|
asks remoteHostSessions >>= atomically . TM.lookup remoteHostId >>= \case
|
2023-10-04 18:36:10 +03:00
|
|
|
Just _ -> throwError $ ChatErrorRemoteHost remoteHostId RHBusy
|
2023-10-07 16:23:24 +03:00
|
|
|
Nothing -> withRemoteHost remoteHostId $ \rh -> do
|
|
|
|
announcer <- async $ run rh
|
2023-10-04 18:36:10 +03:00
|
|
|
chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarting {announcer}
|
|
|
|
pure CRRemoteHostStarted {remoteHostId}
|
2023-10-07 16:23:24 +03:00
|
|
|
where
|
|
|
|
cleanup finished = do
|
|
|
|
logInfo "Remote host http2 client fininshed"
|
|
|
|
atomically $ writeTVar finished True
|
|
|
|
closeRemoteHostSession remoteHostId >>= toView
|
|
|
|
run RemoteHost {storePath, caKey, caCert} = do
|
|
|
|
finished <- newTVarIO False
|
|
|
|
let parent = (C.signatureKeyPair caKey, caCert)
|
|
|
|
sessionCreds <- liftIO $ genCredentials (Just parent) (0, 24) "Session"
|
|
|
|
let (fingerprint, credentials) = tlsCredentials $ sessionCreds :| [parent]
|
|
|
|
Discovery.announceRevHTTP2 (cleanup finished) fingerprint credentials >>= \case
|
|
|
|
Left h2ce -> do
|
|
|
|
logError $ "Failed to set up remote host connection: " <> tshow h2ce
|
|
|
|
cleanup finished
|
|
|
|
Right ctrlClient -> do
|
|
|
|
chatModifyVar remoteHostSessions $ M.insert remoteHostId RemoteHostSessionStarted {storePath, ctrlClient}
|
|
|
|
chatWriteVar currentRemoteHost $ Just remoteHostId
|
|
|
|
sendHello ctrlClient >>= \case
|
|
|
|
Left h2ce -> do
|
|
|
|
logError $ "Failed to send initial remote host request: " <> tshow h2ce
|
|
|
|
cleanup finished
|
|
|
|
Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do
|
|
|
|
logDebug $ "Got initial from remote host: " <> tshow bodyHead
|
2023-10-11 11:45:05 +03:00
|
|
|
oq <- asks outputQ
|
|
|
|
let toViewRemote = atomically . writeTBQueue oq . (Nothing,Just remoteHostId,)
|
|
|
|
void . async $ pollRemote finished ctrlClient "/recv" $ \chatResponse -> do
|
|
|
|
case chatResponse of
|
|
|
|
CRRcvFileComplete {user = ru, chatItem = AChatItem c d@SMDRcv i ci@ChatItem {file = Just ciFile}} -> do
|
|
|
|
handleRcvFileComplete ctrlClient storePath ru ciFile >>= \case
|
|
|
|
Nothing -> toViewRemote chatResponse
|
|
|
|
Just localFile -> toViewRemote CRRcvFileComplete {user = ru, chatItem = AChatItem c d i ci {file = Just localFile}}
|
|
|
|
_ -> toViewRemote chatResponse
|
2023-10-07 16:23:24 +03:00
|
|
|
toView CRRemoteHostConnected {remoteHostId}
|
|
|
|
|
|
|
|
sendHello :: (ChatMonad m) => HTTP2Client -> m (Either HTTP2.HTTP2ClientError HTTP2.HTTP2Response)
|
|
|
|
sendHello http = liftIO (HTTP2.sendRequestDirect http req Nothing)
|
|
|
|
where
|
|
|
|
req = HTTP2Client.requestNoBody "GET" "/" mempty
|
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
pollRemote :: (ChatMonad m, J.FromJSON a) => TVar Bool -> HTTP2Client -> ByteString -> (a -> m ()) -> m ()
|
|
|
|
pollRemote finished http path action = loop
|
2023-10-07 16:23:24 +03:00
|
|
|
where
|
|
|
|
loop = do
|
|
|
|
liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case
|
|
|
|
Left e -> logError $ "pollRemote: " <> tshow (path, e)
|
2023-10-11 11:45:05 +03:00
|
|
|
Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do
|
|
|
|
logDebug $ "Got /recv response: " <> decodeUtf8 bodyHead
|
2023-10-07 16:23:24 +03:00
|
|
|
case J.eitherDecodeStrict' bodyHead of
|
|
|
|
Left e -> logError $ "pollRemote/decode: " <> tshow (path, e)
|
2023-10-11 11:45:05 +03:00
|
|
|
Right o -> action o
|
2023-10-07 16:23:24 +03:00
|
|
|
readTVarIO finished >>= (`unless` loop)
|
|
|
|
req = HTTP2Client.requestNoBody "GET" path mempty
|
2023-10-04 18:36:10 +03:00
|
|
|
|
|
|
|
closeRemoteHostSession :: (ChatMonad m) => RemoteHostId -> m ChatResponse
|
|
|
|
closeRemoteHostSession remoteHostId = withRemoteHostSession remoteHostId $ \session -> do
|
2023-10-07 16:23:24 +03:00
|
|
|
liftIO $ cancelRemoteHostSession session
|
|
|
|
chatWriteVar currentRemoteHost Nothing
|
2023-10-04 18:36:10 +03:00
|
|
|
chatModifyVar remoteHostSessions $ M.delete remoteHostId
|
2023-10-07 16:23:24 +03:00
|
|
|
pure CRRemoteHostStopped {remoteHostId}
|
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
cancelRemoteHostSession :: (MonadUnliftIO m) => RemoteHostSession -> m ()
|
2023-10-07 16:23:24 +03:00
|
|
|
cancelRemoteHostSession = \case
|
|
|
|
RemoteHostSessionStarting {announcer} -> cancel announcer
|
|
|
|
RemoteHostSessionStarted {ctrlClient} -> liftIO $ HTTP2.closeHTTP2Client ctrlClient
|
2023-10-04 18:36:10 +03:00
|
|
|
|
|
|
|
createRemoteHost :: (ChatMonad m) => m ChatResponse
|
|
|
|
createRemoteHost = do
|
|
|
|
let displayName = "TODO" -- you don't have remote host name here, it will be passed from remote host
|
|
|
|
((_, caKey), caCert) <- liftIO $ genCredentials Nothing (-25, 24 * 365) displayName
|
|
|
|
storePath <- liftIO randomStorePath
|
|
|
|
remoteHostId <- withStore' $ \db -> insertRemoteHost db storePath displayName caKey caCert
|
2023-10-07 16:23:24 +03:00
|
|
|
let oobData = RemoteCtrlOOB {caFingerprint = C.certificateFingerprint caCert}
|
2023-10-04 18:36:10 +03:00
|
|
|
pure CRRemoteHostCreated {remoteHostId, oobData}
|
|
|
|
|
|
|
|
-- | Generate a random 16-char filepath without / in it by using base64url encoding.
|
|
|
|
randomStorePath :: IO FilePath
|
|
|
|
randomStorePath = B.unpack . B64U.encode <$> getRandomBytes 12
|
|
|
|
|
|
|
|
listRemoteHosts :: (ChatMonad m) => m ChatResponse
|
|
|
|
listRemoteHosts = do
|
|
|
|
stored <- withStore' getRemoteHosts
|
|
|
|
active <- chatReadVar remoteHostSessions
|
|
|
|
pure $ CRRemoteHostList $ do
|
|
|
|
RemoteHost {remoteHostId, storePath, displayName} <- stored
|
|
|
|
let sessionActive = M.member remoteHostId active
|
|
|
|
pure RemoteHostInfo {remoteHostId, storePath, displayName, sessionActive}
|
|
|
|
|
|
|
|
deleteRemoteHost :: (ChatMonad m) => RemoteHostId -> m ChatResponse
|
|
|
|
deleteRemoteHost remoteHostId = withRemoteHost remoteHostId $ \rh -> do
|
|
|
|
-- TODO: delete files
|
|
|
|
withStore' $ \db -> deleteRemoteHostRecord db remoteHostId
|
|
|
|
pure CRRemoteHostDeleted {remoteHostId}
|
2023-09-27 13:40:19 +03:00
|
|
|
|
2023-09-27 18:24:38 +03:00
|
|
|
processRemoteCommand :: (ChatMonad m) => RemoteHostSession -> (ByteString, ChatCommand) -> m ChatResponse
|
2023-10-07 16:23:24 +03:00
|
|
|
processRemoteCommand RemoteHostSessionStarting {} _ = pure . CRChatError Nothing . ChatError $ CEInternalError "sending remote commands before session started"
|
|
|
|
processRemoteCommand RemoteHostSessionStarted {ctrlClient} (s, cmd) = do
|
2023-10-11 11:45:05 +03:00
|
|
|
logDebug $ "processRemoteCommand: " <> tshow (s, cmd)
|
|
|
|
case cmd of
|
|
|
|
SendFile cn ctrlPath -> do
|
|
|
|
storeRemoteFile ctrlClient ctrlPath >>= \case
|
|
|
|
-- TODO: use Left
|
|
|
|
Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host"
|
|
|
|
Just hostPath -> relayCommand ctrlClient $ "/file " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath
|
|
|
|
SendImage cn ctrlPath -> do
|
|
|
|
storeRemoteFile ctrlClient ctrlPath >>= \case
|
|
|
|
Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store image on remote host"
|
|
|
|
Just hostPath -> relayCommand ctrlClient $ "/image " <> utf8String (chatNameStr cn) <> " " <> utf8String hostPath
|
|
|
|
APISendMessage {composedMessage = cm@ComposedMessage {fileSource = Just CryptoFile {filePath = ctrlPath, cryptoArgs}}} -> do
|
|
|
|
storeRemoteFile ctrlClient ctrlPath >>= \case
|
|
|
|
Nothing -> pure . CRChatError Nothing . ChatError $ CEInternalError "failed to store file on remote host"
|
|
|
|
Just hostPath -> do
|
|
|
|
let cm' = cm {fileSource = Just CryptoFile {filePath = hostPath, cryptoArgs}} :: ComposedMessage
|
|
|
|
relayCommand ctrlClient $ B.takeWhile (/= '{') s <> B.toStrict (J.encode cm')
|
|
|
|
_ -> relayCommand ctrlClient s
|
2023-09-27 11:41:02 +03:00
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
relayCommand :: (ChatMonad m) => HTTP2Client -> ByteString -> m ChatResponse
|
|
|
|
relayCommand http s =
|
2023-10-07 16:23:24 +03:00
|
|
|
postBytestring Nothing http "/send" mempty s >>= \case
|
|
|
|
Left e -> err $ "relayCommand/post: " <> show e
|
2023-09-27 18:24:38 +03:00
|
|
|
Right HTTP2.HTTP2Response {respBody = HTTP2Body {bodyHead}} -> do
|
2023-10-11 11:45:05 +03:00
|
|
|
logDebug $ "Got /send response: " <> decodeUtf8 bodyHead
|
2023-10-07 16:23:24 +03:00
|
|
|
remoteChatResponse <- case J.eitherDecodeStrict bodyHead of -- XXX: large JSONs can overflow into buffered chunks
|
|
|
|
Left e -> err $ "relayCommand/decodeValue: " <> show e
|
|
|
|
Right json -> case J.fromJSON $ toTaggedJSON json of
|
|
|
|
J.Error e -> err $ "relayCommand/fromJSON: " <> show e
|
|
|
|
J.Success cr -> pure cr
|
2023-09-27 18:24:38 +03:00
|
|
|
case remoteChatResponse of
|
|
|
|
-- TODO: intercept file responses and fetch files when needed
|
|
|
|
-- XXX: is that even possible, to have a file response to a command?
|
|
|
|
_ -> pure remoteChatResponse
|
2023-09-27 11:41:02 +03:00
|
|
|
where
|
2023-10-07 16:23:24 +03:00
|
|
|
err = pure . CRChatError Nothing . ChatError . CEInternalError
|
|
|
|
toTaggedJSON :: J.Value -> J.Value
|
|
|
|
toTaggedJSON = id -- owsf2tagged TODO: get from RemoteHost
|
2023-09-27 11:41:02 +03:00
|
|
|
-- XXX: extract to http2 transport
|
2023-10-07 16:23:24 +03:00
|
|
|
postBytestring timeout' c path hs body = liftIO $ HTTP2.sendRequestDirect c req timeout'
|
2023-09-27 11:41:02 +03:00
|
|
|
where
|
|
|
|
req = HTTP2Client.requestBuilder "POST" path hs (Binary.fromByteString body)
|
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
handleRcvFileComplete :: (ChatMonad m) => HTTP2Client -> FilePath -> User -> CIFile 'MDRcv -> m (Maybe (CIFile 'MDRcv))
|
|
|
|
handleRcvFileComplete http storePath remoteUser cif@CIFile {fileId, fileName, fileStatus} = case fileStatus of
|
|
|
|
CIFSRcvComplete ->
|
|
|
|
chatReadVar filesFolder >>= \case
|
|
|
|
Just baseDir -> do
|
|
|
|
let hostStore = baseDir </> storePath
|
|
|
|
createDirectoryIfMissing True hostStore
|
|
|
|
localPath <- uniqueCombine hostStore fileName
|
|
|
|
ok <- fetchRemoteFile http remoteUser fileId localPath
|
|
|
|
pure $ Just (cif {fileName = localPath} :: CIFile 'MDRcv)
|
|
|
|
Nothing -> Nothing <$ logError "Local file store not available while fetching remote file"
|
|
|
|
_ -> Nothing <$ logDebug ("Ingoring invalid file notification for file (" <> tshow fileId <> ") " <> tshow fileName)
|
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
-- | Convert swift single-field sum encoding into tagged/discriminator-field
|
2023-10-07 16:23:24 +03:00
|
|
|
owsf2tagged :: J.Value -> J.Value
|
2023-10-11 19:11:01 +01:00
|
|
|
owsf2tagged = fst . convert
|
|
|
|
where
|
|
|
|
convert val = case val of
|
|
|
|
J.Object o
|
|
|
|
| JM.size o == 2 ->
|
|
|
|
case JM.toList o of
|
|
|
|
[OwsfTag, o'] -> tagged o'
|
|
|
|
[o', OwsfTag] -> tagged o'
|
|
|
|
_ -> props
|
|
|
|
| otherwise -> props
|
|
|
|
where
|
|
|
|
props = (J.Object $ fmap owsf2tagged o, False)
|
|
|
|
J.Array a -> (J.Array $ fmap owsf2tagged a, False)
|
|
|
|
_ -> (val, False)
|
|
|
|
-- `tagged` converts the pair of single-field object encoding to tagged encoding.
|
|
|
|
-- It sets innerTag returned by `convert` to True to prevent the tag being overwritten.
|
|
|
|
tagged (k, v) = (J.Object pairs, True)
|
|
|
|
where
|
|
|
|
(v', innerTag) = convert v
|
|
|
|
pairs = case v' of
|
|
|
|
-- `innerTag` indicates that internal object already has tag,
|
|
|
|
-- so the current tag cannot be inserted into it.
|
|
|
|
J.Object o
|
|
|
|
| innerTag -> pair
|
|
|
|
| otherwise -> JM.insert TaggedObjectJSONTag tag o
|
|
|
|
_ -> pair
|
|
|
|
tag = J.String $ JK.toText k
|
|
|
|
pair = JM.fromList [TaggedObjectJSONTag .= tag, TaggedObjectJSONData .= v']
|
|
|
|
|
|
|
|
pattern OwsfTag :: (JK.Key, J.Value)
|
|
|
|
pattern OwsfTag = (SingleFieldJSONTag, J.Bool True)
|
2023-10-04 18:36:10 +03:00
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
storeRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> FilePath -> m (Maybe FilePath)
|
2023-10-04 18:36:10 +03:00
|
|
|
storeRemoteFile http localFile = do
|
2023-10-11 11:45:05 +03:00
|
|
|
putFile Nothing http uri mempty localFile >>= \case
|
|
|
|
Left h2ce -> Nothing <$ logError (tshow h2ce)
|
|
|
|
Right HTTP2.HTTP2Response {response, respBody = HTTP2Body {bodyHead}} ->
|
|
|
|
case HTTP.statusCode <$> HTTP2Client.responseStatus response of
|
|
|
|
Just 200 -> pure . Just $ B.unpack bodyHead
|
|
|
|
notOk -> Nothing <$ logError ("Bad response status: " <> tshow notOk)
|
2023-09-27 11:41:02 +03:00
|
|
|
where
|
2023-10-11 11:45:05 +03:00
|
|
|
uri = "/store?" <> HTTP.renderSimpleQuery False [("file_name", utf8String $ takeFileName localFile)]
|
2023-10-12 10:43:59 +01:00
|
|
|
putFile timeout' c path hs file = liftIO $ do
|
2023-09-27 11:41:02 +03:00
|
|
|
fileSize <- fromIntegral <$> getFileSize file
|
2023-10-12 10:43:59 +01:00
|
|
|
HTTP2.sendRequestDirect c (req fileSize) timeout'
|
2023-09-27 11:41:02 +03:00
|
|
|
where
|
2023-10-07 16:23:24 +03:00
|
|
|
req size = HTTP2Client.requestFile "PUT" path hs (HTTP2Client.FileSpec file 0 size)
|
2023-09-27 11:41:02 +03:00
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
fetchRemoteFile :: (MonadUnliftIO m) => HTTP2Client -> User -> Int64 -> FilePath -> m Bool
|
|
|
|
fetchRemoteFile http User {userId = remoteUserId} remoteFileId localPath = do
|
2023-10-07 16:23:24 +03:00
|
|
|
liftIO (HTTP2.sendRequestDirect http req Nothing) >>= \case
|
2023-10-11 11:45:05 +03:00
|
|
|
Left h2ce -> False <$ logError (tshow h2ce)
|
|
|
|
Right HTTP2.HTTP2Response {response, respBody} ->
|
|
|
|
if HTTP2Client.responseStatus response == Just Status.ok200
|
|
|
|
then True <$ writeBodyToFile localPath respBody
|
|
|
|
else False <$ (logError $ "Request failed: " <> maybe "(??)" tshow (HTTP2Client.responseStatus response) <> " " <> decodeUtf8 (bodyHead respBody))
|
2023-09-27 11:41:02 +03:00
|
|
|
where
|
|
|
|
req = HTTP2Client.requestNoBody "GET" path mempty
|
2023-10-11 11:45:05 +03:00
|
|
|
path = "/fetch?" <> HTTP.renderSimpleQuery False [("user_id", bshow remoteUserId), ("file_id", bshow remoteFileId)]
|
|
|
|
|
|
|
|
-- XXX: extract to Transport.HTTP2 ?
|
|
|
|
writeBodyToFile :: (MonadUnliftIO m) => FilePath -> HTTP2Body -> m ()
|
|
|
|
writeBodyToFile path HTTP2Body {bodyHead, bodySize, bodyPart} = do
|
|
|
|
logInfo $ "Receiving " <> tshow bodySize <> " bytes to " <> tshow path
|
|
|
|
liftIO . withFile path WriteMode $ \h -> do
|
|
|
|
hPut h bodyHead
|
|
|
|
mapM_ (hPutBodyChunks h) bodyPart
|
|
|
|
|
|
|
|
hPutBodyChunks :: Handle -> (Int -> IO ByteString) -> IO ()
|
|
|
|
hPutBodyChunks h getChunk = do
|
|
|
|
chunk <- getChunk defaultHTTP2BufferSize
|
|
|
|
unless (B.null chunk) $ do
|
|
|
|
hPut h chunk
|
|
|
|
hPutBodyChunks h getChunk
|
|
|
|
|
|
|
|
processControllerRequest :: forall m. (ChatMonad m) => (ByteString -> m ChatResponse) -> HTTP2.HTTP2Request -> m ()
|
|
|
|
processControllerRequest execChatCommand HTTP2.HTTP2Request {request, reqBody, sendResponse} = do
|
|
|
|
logDebug $ "Remote controller request: " <> tshow (method <> " " <> path)
|
|
|
|
res <- tryChatError $ case (method, ps) of
|
|
|
|
("GET", []) -> getHello
|
|
|
|
("POST", ["send"]) -> sendCommand
|
|
|
|
("GET", ["recv"]) -> recvMessage
|
|
|
|
("PUT", ["store"]) -> storeFile
|
|
|
|
("GET", ["fetch"]) -> fetchFile
|
2023-10-07 16:23:24 +03:00
|
|
|
unexpected -> respondWith Status.badRequest400 $ "unexpected method/path: " <> Binary.putStringUtf8 (show unexpected)
|
|
|
|
case res of
|
|
|
|
Left e -> logError $ "Error handling remote controller request: (" <> tshow (method <> " " <> path) <> "): " <> tshow e
|
|
|
|
Right () -> logDebug $ "Remote controller request: " <> tshow (method <> " " <> path) <> " OK"
|
|
|
|
where
|
|
|
|
method = fromMaybe "" $ HTTP2Server.requestMethod request
|
2023-10-11 11:45:05 +03:00
|
|
|
path = fromMaybe "/" $ HTTP2Server.requestPath request
|
|
|
|
(ps, query) = HTTP.decodePath path
|
2023-10-07 16:23:24 +03:00
|
|
|
getHello = respond "OK"
|
2023-10-11 11:45:05 +03:00
|
|
|
sendCommand = execChatCommand (bodyHead reqBody) >>= respondJSON
|
|
|
|
recvMessage =
|
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> respondWith Status.internalServerError500 "session not active"
|
|
|
|
Just rcs -> atomically (readTBQueue $ remoteOutputQ rcs) >>= respondJSON
|
|
|
|
storeFile = case storeFileQuery of
|
|
|
|
Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err)
|
|
|
|
Right fileName -> do
|
|
|
|
baseDir <- fromMaybe "." <$> chatReadVar filesFolder
|
|
|
|
localPath <- uniqueCombine baseDir fileName
|
|
|
|
logDebug $ "Storing controller file to " <> tshow (baseDir, localPath)
|
|
|
|
writeBodyToFile localPath reqBody
|
|
|
|
let storeRelative = takeFileName localPath
|
|
|
|
respond $ Binary.putStringUtf8 storeRelative
|
|
|
|
where
|
|
|
|
storeFileQuery = parseField "file_name" $ A.many1 (A.satisfy $ not . isPathSeparator)
|
|
|
|
fetchFile = case fetchFileQuery of
|
|
|
|
Left err -> respondWith Status.badRequest400 (Binary.putStringUtf8 err)
|
|
|
|
Right (userId, fileId) -> do
|
|
|
|
logInfo $ "Fetching file " <> tshow fileId <> " from user " <> tshow userId
|
|
|
|
x <- withStore' $ \db -> runExceptT $ do
|
|
|
|
user <- getUser db userId
|
|
|
|
getRcvFileTransfer db user fileId
|
|
|
|
case x of
|
|
|
|
Right RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}} -> do
|
|
|
|
baseDir <- fromMaybe "." <$> chatReadVar filesFolder
|
|
|
|
let fullPath = baseDir </> filePath
|
|
|
|
size <- fromInteger <$> getFileSize fullPath
|
|
|
|
liftIO . sendResponse . HTTP2Server.responseFile Status.ok200 mempty $ HTTP2Server.FileSpec fullPath 0 size
|
|
|
|
Right _ -> respondWith Status.internalServerError500 "The requested file is not complete"
|
|
|
|
Left SEUserNotFound {} -> respondWith Status.notFound404 "User not found"
|
|
|
|
Left SERcvFileNotFound {} -> respondWith Status.notFound404 "File not found"
|
|
|
|
_ -> respondWith Status.internalServerError500 "Store error"
|
|
|
|
where
|
|
|
|
fetchFileQuery =
|
|
|
|
(,)
|
|
|
|
<$> parseField "user_id" A.decimal
|
|
|
|
<*> parseField "file_id" A.decimal
|
|
|
|
|
|
|
|
parseField :: ByteString -> A.Parser a -> Either String a
|
|
|
|
parseField field p = maybe (Left $ "missing " <> B.unpack field) (A.parseOnly $ p <* A.endOfInput) (join $ lookup field query)
|
|
|
|
|
|
|
|
respondJSON :: (J.ToJSON a) => a -> m ()
|
2023-10-07 16:23:24 +03:00
|
|
|
respondJSON = respond . Binary.fromLazyByteString . J.encode
|
|
|
|
|
|
|
|
respond = respondWith Status.ok200
|
|
|
|
respondWith status = liftIO . sendResponse . HTTP2Server.responseBuilder status []
|
2023-09-29 14:56:56 +03:00
|
|
|
|
|
|
|
-- * ChatRequest handlers
|
|
|
|
|
2023-10-07 16:23:24 +03:00
|
|
|
startRemoteCtrl :: (ChatMonad m) => (ByteString -> m ChatResponse) -> m ChatResponse
|
|
|
|
startRemoteCtrl execChatCommand =
|
2023-09-29 14:56:56 +03:00
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Just _busy -> throwError $ ChatErrorRemoteCtrl RCEBusy
|
|
|
|
Nothing -> do
|
2023-10-07 16:23:24 +03:00
|
|
|
size <- asks $ tbqSize . config
|
|
|
|
remoteOutputQ <- newTBQueueIO size
|
2023-09-29 14:56:56 +03:00
|
|
|
discovered <- newTVarIO mempty
|
2023-10-04 18:36:10 +03:00
|
|
|
discoverer <- async $ discoverRemoteCtrls discovered
|
2023-10-07 16:23:24 +03:00
|
|
|
accepted <- newEmptyTMVarIO
|
2023-10-04 18:36:10 +03:00
|
|
|
supervisor <- async $ do
|
|
|
|
remoteCtrlId <- atomically (readTMVar accepted)
|
|
|
|
withRemoteCtrl remoteCtrlId $ \RemoteCtrl {displayName, fingerprint} -> do
|
|
|
|
source <- atomically $ TM.lookup fingerprint discovered >>= maybe retry pure
|
|
|
|
toView $ CRRemoteCtrlConnecting {remoteCtrlId, displayName}
|
|
|
|
atomically $ writeTVar discovered mempty -- flush unused sources
|
2023-10-07 16:23:24 +03:00
|
|
|
server <- async $ Discovery.connectRevHTTP2 source fingerprint (processControllerRequest execChatCommand)
|
2023-10-04 18:36:10 +03:00
|
|
|
chatModifyVar remoteCtrlSession $ fmap $ \s -> s {hostServer = Just server}
|
|
|
|
toView $ CRRemoteCtrlConnected {remoteCtrlId, displayName}
|
|
|
|
_ <- waitCatch server
|
|
|
|
chatWriteVar remoteCtrlSession Nothing
|
2023-10-12 10:43:59 +01:00
|
|
|
toView CRRemoteCtrlStopped
|
|
|
|
chatWriteVar remoteCtrlSession $ Just RemoteCtrlSession {discoverer, supervisor, hostServer = Nothing, discovered, accepted, remoteOutputQ}
|
|
|
|
pure CRRemoteCtrlStarted
|
2023-09-29 14:56:56 +03:00
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
discoverRemoteCtrls :: (ChatMonad m) => TM.TMap C.KeyHash TransportHost -> m ()
|
2023-10-07 16:23:24 +03:00
|
|
|
discoverRemoteCtrls discovered = Discovery.withListener go
|
2023-09-29 14:56:56 +03:00
|
|
|
where
|
|
|
|
go sock =
|
|
|
|
Discovery.recvAnnounce sock >>= \case
|
2023-10-07 16:23:24 +03:00
|
|
|
(SockAddrInet _sockPort sockAddr, invite) -> case strDecode invite of
|
2023-09-29 14:56:56 +03:00
|
|
|
Left _ -> go sock -- ignore malformed datagrams
|
|
|
|
Right fingerprint -> do
|
2023-10-07 16:23:24 +03:00
|
|
|
let addr = THIPv4 (hostAddressToTuple sockAddr)
|
|
|
|
ifM
|
|
|
|
(atomically $ TM.member fingerprint discovered)
|
2023-10-11 11:45:05 +03:00
|
|
|
(logDebug $ "Fingerprint announce already knwon: " <> tshow (addr, invite))
|
|
|
|
( do
|
|
|
|
logInfo $ "New fingerprint announce: " <> tshow (addr, invite)
|
|
|
|
atomically $ TM.insert fingerprint addr discovered
|
2023-10-07 16:23:24 +03:00
|
|
|
)
|
2023-10-04 18:36:10 +03:00
|
|
|
withStore' (`getRemoteCtrlByFingerprint` fingerprint) >>= \case
|
2023-10-07 16:23:24 +03:00
|
|
|
Nothing -> toView $ CRRemoteCtrlAnnounce fingerprint -- unknown controller, ui "register" action required
|
2023-10-11 11:45:05 +03:00
|
|
|
Just found@RemoteCtrl {remoteCtrlId, accepted = storedChoice} -> case storedChoice of
|
2023-10-07 16:23:24 +03:00
|
|
|
Nothing -> toView $ CRRemoteCtrlFound found -- first-time controller, ui "accept" action required
|
2023-10-04 18:36:10 +03:00
|
|
|
Just False -> pure () -- skipping a rejected item
|
2023-10-11 11:45:05 +03:00
|
|
|
Just True ->
|
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> toView . CRChatError Nothing . ChatError $ CEInternalError "Remote host found without running a session"
|
|
|
|
Just RemoteCtrlSession {accepted} -> atomically $ void $ tryPutTMVar accepted remoteCtrlId -- previously accepted controller, connect automatically
|
2023-09-29 14:56:56 +03:00
|
|
|
_nonV4 -> go sock
|
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
registerRemoteCtrl :: (ChatMonad m) => RemoteCtrlOOB -> m ChatResponse
|
|
|
|
registerRemoteCtrl RemoteCtrlOOB {caFingerprint} = do
|
|
|
|
let displayName = "TODO" -- maybe include into OOB data
|
|
|
|
remoteCtrlId <- withStore' $ \db -> insertRemoteCtrl db displayName caFingerprint
|
|
|
|
pure $ CRRemoteCtrlRegistered {remoteCtrlId}
|
2023-09-29 14:56:56 +03:00
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
listRemoteCtrls :: (ChatMonad m) => m ChatResponse
|
|
|
|
listRemoteCtrls = do
|
|
|
|
stored <- withStore' getRemoteCtrls
|
|
|
|
active <-
|
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> pure Nothing
|
|
|
|
Just RemoteCtrlSession {accepted} -> atomically (tryReadTMVar accepted)
|
|
|
|
pure $ CRRemoteCtrlList $ do
|
|
|
|
RemoteCtrl {remoteCtrlId, displayName} <- stored
|
|
|
|
let sessionActive = active == Just remoteCtrlId
|
|
|
|
pure RemoteCtrlInfo {remoteCtrlId, displayName, sessionActive}
|
|
|
|
|
|
|
|
acceptRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse
|
|
|
|
acceptRemoteCtrl remoteCtrlId = do
|
|
|
|
withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId True
|
2023-09-29 14:56:56 +03:00
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive
|
2023-10-04 18:36:10 +03:00
|
|
|
Just RemoteCtrlSession {accepted} -> atomically . void $ tryPutTMVar accepted remoteCtrlId -- the remote host can now proceed with connection
|
|
|
|
pure $ CRRemoteCtrlAccepted {remoteCtrlId}
|
2023-09-29 14:56:56 +03:00
|
|
|
|
|
|
|
rejectRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse
|
2023-10-04 18:36:10 +03:00
|
|
|
rejectRemoteCtrl remoteCtrlId = do
|
|
|
|
withStore' $ \db -> markRemoteCtrlResolution db remoteCtrlId False
|
2023-09-29 14:56:56 +03:00
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive
|
2023-10-04 18:36:10 +03:00
|
|
|
Just RemoteCtrlSession {discoverer, supervisor} -> do
|
|
|
|
cancel discoverer
|
|
|
|
cancel supervisor
|
|
|
|
pure $ CRRemoteCtrlRejected {remoteCtrlId}
|
2023-09-29 14:56:56 +03:00
|
|
|
|
2023-10-07 16:23:24 +03:00
|
|
|
stopRemoteCtrl :: (ChatMonad m) => m ChatResponse
|
|
|
|
stopRemoteCtrl =
|
2023-09-29 14:56:56 +03:00
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> throwError $ ChatErrorRemoteCtrl RCEInactive
|
2023-10-07 16:23:24 +03:00
|
|
|
Just rcs -> do
|
|
|
|
cancelRemoteCtrlSession rcs $ do
|
|
|
|
chatWriteVar remoteCtrlSession Nothing
|
2023-10-12 10:43:59 +01:00
|
|
|
toView CRRemoteCtrlStopped
|
2023-10-04 18:36:10 +03:00
|
|
|
pure $ CRCmdOk Nothing
|
2023-09-29 14:56:56 +03:00
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
cancelRemoteCtrlSession_ :: (MonadUnliftIO m) => RemoteCtrlSession -> m ()
|
2023-10-07 16:23:24 +03:00
|
|
|
cancelRemoteCtrlSession_ rcs = cancelRemoteCtrlSession rcs $ pure ()
|
|
|
|
|
2023-10-11 11:45:05 +03:00
|
|
|
cancelRemoteCtrlSession :: (MonadUnliftIO m) => RemoteCtrlSession -> m () -> m ()
|
2023-10-07 16:23:24 +03:00
|
|
|
cancelRemoteCtrlSession RemoteCtrlSession {discoverer, supervisor, hostServer} cleanup = do
|
|
|
|
cancel discoverer -- may be gone by now
|
|
|
|
case hostServer of
|
|
|
|
Just host -> cancel host -- supervisor will clean up
|
|
|
|
Nothing -> do
|
|
|
|
cancel supervisor -- supervisor is blocked until session progresses
|
|
|
|
cleanup
|
|
|
|
|
2023-10-04 18:36:10 +03:00
|
|
|
deleteRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> m ChatResponse
|
|
|
|
deleteRemoteCtrl remoteCtrlId =
|
2023-09-29 14:56:56 +03:00
|
|
|
chatReadVar remoteCtrlSession >>= \case
|
|
|
|
Nothing -> do
|
2023-10-04 18:36:10 +03:00
|
|
|
withStore' $ \db -> deleteRemoteCtrlRecord db remoteCtrlId
|
|
|
|
pure $ CRRemoteCtrlDeleted {remoteCtrlId}
|
2023-09-29 14:56:56 +03:00
|
|
|
Just _ -> throwError $ ChatErrorRemoteCtrl RCEBusy
|
2023-10-04 18:36:10 +03:00
|
|
|
|
|
|
|
withRemoteCtrl :: (ChatMonad m) => RemoteCtrlId -> (RemoteCtrl -> m a) -> m a
|
|
|
|
withRemoteCtrl remoteCtrlId action =
|
|
|
|
withStore' (`getRemoteCtrl` remoteCtrlId) >>= \case
|
|
|
|
Nothing -> throwError $ ChatErrorRemoteCtrl RCEMissing {remoteCtrlId}
|
|
|
|
Just rc -> action rc
|
2023-10-11 11:45:05 +03:00
|
|
|
|
|
|
|
utf8String :: [Char] -> ByteString
|
|
|
|
utf8String = encodeUtf8 . T.pack
|
|
|
|
{-# INLINE utf8String #-}
|