{-# Language TemplateHaskell, OverloadedStrings, BangPatterns , ScopedTypeVariables, TypeApplications #-} {-| Module : Client.State.DCC Description : CTCP DCC transfer handling Copyright : (c) Ruben Astudillo, 2019 License : ISC Maintainer : ruben.astud@gmail.com This module provides ADTs and functions to deal with DCC SEND/ACCEPT request and how start such transfers. -} module Client.State.DCC ( DCCState(..) , dsOffers , dsTransfers , emptyDCCState -- * DCC offers , DCCOffer(..) , dccNetwork , dccFromInfo , dccFromIP , dccPort , dccFileName , dccSize , dccOffset , dccStatus -- * DCC transfer , DCCTransfer(..) , dtThread , dtProgress , ConnectionStatus(..) -- * DCC Update , DCCUpdate(..) -- * Transfer a DCCOffer , supervisedDownload -- * Parser for DCC request , parseSEND , parseACCEPT -- * DCC RESUME functionality , resumeMsg , acceptUpdate -- * Miscellaneous , getFileOffset , insertAsNewMax , ctcpToTuple , statusAtKey , reportStopWithStatus , isSend ) where import Control.Applicative (Alternative(..)) import Control.Concurrent.Async import Control.Concurrent.STM import Control.Exception (bracket, IOException) import qualified Control.Exception as E import Control.Lens hiding (from) import Control.Monad (unless, when) import Data.Attoparsec.Text import qualified Data.ByteString as B import Data.ByteString.Builder (word32BE, toLazyByteString) import Data.ByteString.Lazy (toStrict) import Data.IntMap (Key, IntMap) import qualified Data.IntMap as I hiding (size, empty) import Data.List (find) import Data.Text (Text) import qualified Data.Text as Text import Data.Word (Word32, Word64) import Hookup import Irc.Identifier (Identifier, idText) import Irc.Message (IrcMsg(..)) import Irc.UserInfo (UserInfo(..), uiNick) import Network.Socket ( HostName, PortNumber, Family(..) , hostAddressToTuple ) import System.FilePath ((), takeFileName) import System.IO (withFile, IOMode(..), openFile, hClose, hFileSize) -- | All the necessary information to start the download data DCCOffer = DCCOffer { _dccNetwork :: !Text , _dccFromInfo :: !UserInfo , _dccFromIP :: HostName -- ^ String of the IPv4 representation , _dccPort :: !PortNumber , _dccFileName :: FilePath -- ^ Guaranteed to be just the name , _dccSize :: !Word32 -- ^ Size of the whole file, per protocol -- restricted to 32-bits , _dccOffset :: !Word32 -- ^ Byte from where the transmission starts , _dccStatus :: !ConnectionStatus } deriving (Show, Eq) -- | Status of a connection at certain @Key@ data ConnectionStatus = CorrectlyFinished | UserKilled | LostConnection | Downloading | Pending | NotExist deriving (Eq, Show) -- | Structure with information of a download accepted via "/dcc accept" -- or "/dcc resume" data DCCTransfer = DCCTransfer { _dtThread :: !(Maybe (Async ())) -- ^ If Nothing, the thread was killed -- and stopped. , _dtProgress :: !Word32 -- ^ Percentage of progress } -- Check the invariants at @statusAtKey@ data DCCState = DCCState { _dsOffers :: !(IntMap DCCOffer) , _dsTransfers :: !(IntMap DCCTransfer) } data DCCUpdate = PercentUpdate !Key !Word32 | Finished !Key | SocketInterrupted !Key | UserInterrupted !Key | Accept !Key !PortNumber !Word32 -- update that DCC RESUME triggers deriving (Show, Eq) -- Word32 is the offset makeLenses ''DCCOffer makeLenses ''DCCTransfer makeLenses ''DCCState emptyDCCState :: DCCState emptyDCCState = DCCState mempty mempty -- | Smart constructor for new DCCOffers. dccOffer :: Text -> UserInfo -> HostName -> PortNumber -> FilePath -> Word32 -> DCCOffer dccOffer network userFrom hostaddr port filename filesize = DCCOffer network userFrom hostaddr port filename filesize 0 Pending -- | Launch a supervisor thread for downloading the offer referred by @Key@ and -- return the DCCState accordingly. supervisedDownload :: FilePath -> Key -> TChan DCCUpdate -> DCCState -> IO DCCState supervisedDownload dir key updChan state = do let Just offer = view (dsOffers . at key) state -- Previously check supervisorThread <- async $ withAsync (startDownload dir key updChan offer) $ \realTransferThread -> do upd <- E.catches (Finished key <$ wait realTransferThread) [ E.Handler (\(_ :: IOException) -> return (SocketInterrupted key)) -- exception thrown by cancel on async >= 2.2 , E.Handler (\(_ :: AsyncCancelled) -> return (UserInterrupted key)) ] atomically (writeTChan updChan upd) let startPercent = percent (_dccOffset offer) (_dccSize offer) newTransfer = DCCTransfer (Just supervisorThread) startPercent newOffer = offer { _dccStatus = Downloading } newState = set (dsOffers . at key) (Just newOffer) $ set (dsTransfers . at key) (Just newTransfer) state return newState -- | startDownload :: FilePath -> Key -> TChan DCCUpdate -> DCCOffer -> IO () startDownload dir key updChan offer@(DCCOffer _ _ from port name totalSize offset _) = do let openMode = if offset > 0 then AppendMode else WriteMode filepath = dir name bracket (connect param) close $ \conn -> bracket (openFile filepath openMode) hClose $ \hdl -> do -- Has to decouple @send@ from @recv@, tells how much -- have we downloaded. recvChan1 <- atomically newTChan recvChan2 <- atomically (dupTChan recvChan1) -- Two threads, one for @send@ the progress to the -- server and another to signal how much progress -- have we done to the main thread. `withAsync` guarantee -- correct exception handling when the user cancels the -- transfer. -- Notice how recvSendLoop starts at offset instead of 0, this -- is so DCC RESUME start acknowledgement as if the starting -- size was recently @recv@. DCC is a mess. withAsync (sendStream totalSize conn recvChan1) $ \outThread -> withAsync (report offer key recvChan2 updChan) $ \_reportThread -> do recvSendLoop offset recvChan1 conn hdl wait outThread where param = ConnectionParams { cpFamily = AF_INET , cpHost = from , cpPort = port , cpSocks = Nothing , cpTls = Nothing } buffSize = 4 * 1024 * 1024 recvSendLoop size chan conn hdl = do bytes <- recv conn buffSize unless (B.null bytes) $ do B.hPut hdl bytes let newSize = size + fromIntegral (B.length bytes) atomically (writeTChan chan newSize) recvSendLoop newSize chan conn hdl -- | @send@ing the current size to the fileserver. As an independent -- acknowledgement stream, it doesn't match the protocol, but matches -- what other clients and servers do in practice. sendStream :: Word32 -> Connection -> TChan Word32 -> IO () sendStream maxSize conn chan = do val <- atomically (readTChan chan) let valBE = toStrict (toLazyByteString (word32BE val)) send conn valBE unless (val >= maxSize) (sendStream maxSize conn chan) -- | Generate @PercentUpdate@ for each percent of download. report :: DCCOffer -> Key -> TChan Word32 -> TChan DCCUpdate -> IO () report offer key input output = compareAndUpdate (percent offset totalsize) where offset = _dccOffset offer totalsize = _dccSize offer compareAndUpdate :: Word32 -> IO () compareAndUpdate prevPercent = do curSize <- atomically $ readTChan input let curPercent = percent curSize totalsize updateEv = PercentUpdate key curPercent if curPercent == 100 then atomically (writeTChan output updateEv) else do when (curPercent > prevPercent) (atomically (writeTChan output updateEv)) compareAndUpdate curPercent -- Avoid overflow via Word64 percent :: Word32 -> Word32 -> Word32 percent a total = fromIntegral (fromIntegral a * 100 `div` fromIntegral total :: Word64) -- | This function can only be called after a @cancel@ has been issued -- on the supervisor thread at @Key@ reportStopWithStatus :: Key -> ConnectionStatus -> DCCState -> DCCState reportStopWithStatus key newstatus = set (dsOffers . ix key . dccStatus) newstatus . set (dsTransfers . ix key . dtThread ) Nothing -- | Parse a "DCC SEND" command. parseSEND :: Text -> UserInfo -> Text -> Either String DCCOffer parseSEND network userFrom = parseOnly (sendFormat network userFrom) sendFormat :: Text -> UserInfo -> Parser DCCOffer sendFormat network userFrom = do name <- string "SEND" *> space *> nameFormat addr <- ipv4Dotted <$ space <*> decimal port <- space *> decimal totalsize <- space *> decimal return (dccOffer network userFrom addr port name totalsize) -- | Parse a "DCC RESUME" command. parseACCEPT :: DCCState -> UserInfo -> Text -> Maybe DCCUpdate parseACCEPT state userFrom text = case parseOnly acceptFormat text of Left _ -> Nothing Right (fileName, port, offset) -> do (key, _) <- find (predicate fileName) offerList return (Accept key port offset) where offerList = I.toDescList (_dsOffers state) predicate fileName (key, offer) = view dccFileName offer == fileName && view dccFromInfo offer == userFrom && statusAtKey key state == Pending acceptFormat :: Parser (FilePath, PortNumber, Word32) acceptFormat = do filepath <- string "ACCEPT" *> space *> nameFormat port <- space *> decimal offset <- space *> decimal return (filepath, port, offset) -- Depending on the software, if the filename contains no spaces, the -- DCC SEND can be sent without a \" enclosing it. Handle that -- correctly. nameFormat :: Parser FilePath nameFormat = do textPath <- try quotedName <|> noSpaceName return (takeFileName (Text.unpack textPath)) where quotedName = char '\"' *> takeWhile1 ('\"' /=) <* char '\"' noSpaceName = takeWhile1 (' ' /=) -- | Assuming little-endian ipv4Dotted :: Word32 -> HostName ipv4Dotted addr = ipv4Format (bigToLittleEndian (hostAddressToTuple addr)) where bigToLittleEndian (a, b, c, d) = (d, c, b, a) ipv4Format (d,c,b,a) = show d <> "." <> show c <> "." <> show b <> "." <> show a getFileOffset :: FilePath -> IO (Maybe Word32) getFileOffset path = do res <- E.try (withFile path ReadMode hFileSize) return $! case res :: Either IOError Integer of Right n | n > 0 -> Just $! fromIntegral n _ -> Nothing insertAsNewMax :: DCCOffer -> DCCState -> DCCState insertAsNewMax newoffer (DCCState offers transfers) = let newmax = if I.null offers then 1 else 1 + fst (I.findMax offers) newOffers = I.insert newmax newoffer offers in DCCState newOffers transfers ctcpToTuple :: IrcMsg -> Maybe (UserInfo, Identifier, Text, Text) ctcpToTuple (Ctcp fromU target command txt) = Just (fromU, target, command, txt) ctcpToTuple (CtcpNotice fromU target command txt) = Just (fromU, target, command, txt) ctcpToTuple _ = Nothing -- | Check the status of a download at @Key@ by checking the invariants -- at @DCCState@ statusAtKey :: Key -> DCCState -> ConnectionStatus statusAtKey key (DCCState offers _) = case I.lookup key offers of Nothing -> NotExist Just d -> view dccStatus d -- | Craft a CTCP message indicating we want to resume a download at the offset. resumeMsg :: Word32 {- ^ offset -} -> DCCOffer {- ^ offer -} -> (String, String) {- ^ (target, txt) -} resumeMsg sizeoffset offer = (target, txt) where filename = _dccFileName offer port = show (_dccPort offer) sizeoffset' = show sizeoffset quoting = if ' ' `elem` filename then "\"" else "" txt = concat ["RESUME ", quoting, filename, quoting, " ", port, " ", sizeoffset' ] target = views (dccFromInfo . uiNick) (Text.unpack . idText) offer -- | Modify the @DCCState@ following the corresponding @DCCUpdate@ acceptUpdate :: DCCUpdate -> DCCState -> DCCState acceptUpdate (Accept k port offset) state = case view (dsOffers . at k) state of Nothing -> state -- check at call-site Just oldOffer -> set (dsOffers . at k) (Just newOffer) state where newOffer = oldOffer { _dccPort = port, _dccOffset = offset } acceptUpdate _ state = state -- | Check if the payload of a "DCC" CTCP message is SEND isSend :: Text -> Bool isSend txt | "SEND":_ <- Text.splitOn " " txt = True | otherwise = False