packages feed

hs-hath-1.2.0: src/Storage/Locate.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TemplateHaskell #-}

module Storage.Locate
  ( LocateURI(..)
  , Locate(..)
  , Located(..)
  , locateResource
  , runLocate
  , streamUpstream
  ) where

import           Colog                   ( Message, Severity(Info, Warning) )
import           Colog.Polysemy          ( Log )

import           Control.Concurrent      ( forkIO )
import           Control.Exception       ( finally )

import qualified Data.ByteString         as BS
import qualified Data.ByteString.Short   as SBS
import qualified Data.Map                as Map
import           Data.String.Interpolate ( i )

import           Interface.API           ( EHentaiAPI, openResource )

import qualified Network.HTTP.Client     as HC

import           Polysemy
import           Polysemy.Error          ( Error, catch )
import           Polysemy.Final          ( withWeavingToFinal )
import           Polysemy.KVStore        ( KVStore, lookupKV, updateKV )
import           Polysemy.Operators
import           Polysemy.Reader         ( Reader )

import           Relude                  hiding ( Reader, ask )

import           SettingM                ( SettingM, getSettings )

import           Stats                   ( Stats, addDownload, incFetched )

import           Types

import           Utils                   ( hash, log )

data LocateURI
  = LocateURI { locateURIFilename :: !ByteString
              , locateURI         :: !FileURI
              , locateURIOptions  :: !(Map ByteString ByteString)
              }

data Located
  = Stored !StorageResult
    -- | Cache miss being relayed from upstream: call it with a chunk writer.
    -- It closes the upstream connection and caches the file once it is complete.
  | Streamed !((ByteString -> IO ()) -> IO ())

data Locate m a where
  -- | Fetch a specific resource from the server
  LocateResource :: LocateURI -> Locate m (Maybe Located)

makeSem ''Locate

runLocate
  :: Members
    '[ KVStore FileURI StorageResult
     , SettingM
     , Error RPCError
     , Reader ClientConfig
     , EHentaiAPI
     , Embed IO
     , Final IO
     , Stats
     , Log Message
     ]
    r
  => Locate : r @> a
  -> r @> a
runLocate = interpret $ \case
  LocateResource uri -> do
    settings <- getSettings
    let fileURI = locateURI uri
        s4      = SBS.toShort $ BS.take 4 $ renderFileURIBytes fileURI
        record content
          = Record
            FileRecord
            { fileRecordLRUCounter = 1
            , fileRecordS4         = decodeUtf8 s4
            , fileRecordFileId     = renderFileURIText fileURI
            , fileRecordFileName   = Just $ decodeUtf8 $ locateURIFilename uri
            , fileRecordBytes      = content
            }
    -- if HashSet.member s4 (staticRanges settings) <-- we have a bug in setting parsing. wait for fix
    if True
      then lookupKV fileURI >>= \case
        Just x -> return $ Just $ Stored x
        Nothing -> case ( Map.lookup "fileindex" (locateURIOptions uri)
                        , Map.lookup "xres" (locateURIOptions uri)
                        ) of
          ( Just fileIndex, Just xres ) -> log Info [i|Fetching resource: #{fileURI}|]
            >> openResource fileURI ( fileIndex, xres )
            >>= \case
              Just resp -> do
                incFetched
                -- runs after the handler has returned, once the body has been relayed
                cacheIt <- deferToIO $ \content -> do
                  addDownload (BS.length content)
                  -- a failed cache write only costs us the next hit
                  catch @RPCError (updateKV fileURI $ Just $ record content)
                    $ \e -> log Warning [i|Cache write failed for #{fileURI}: #{e}|]
                pure $ Just $ Streamed $ streamUpstream fileURI resp (void . forkIO . cacheIt)
              Nothing   -> do
                log Warning [i|Failed to fetch resource, sorry for you|]
                pure Nothing
          _ -> do
            log Info [i|Not enough information to fetch resource: #{fileURI}|]
            pure Nothing
      else do
        log Info [i|Resource not in our ranges, rejecting: #{s4}|]
        pure Nothing

-- | Relay an upstream body chunk by chunk. If it arrives complete and its SHA-1
-- matches, hand the bytes to the continuation (the cache write). The upstream
-- connection is closed in every case, including the client going away.
streamUpstream :: FileURI
               -> HC.Response HC.BodyReader
               -> (ByteString -> IO ())
               -> (ByteString -> IO ())
               -> IO ()
streamUpstream fileURI resp whenComplete write = go [] `finally` HC.responseClose resp
  where
    go acc = HC.brRead (HC.responseBody resp) >>= \chunk -> if BS.null chunk
      then let
          content = BS.concat (reverse acc)
        in
          when (BS.length content == fileSize fileURI && hash content == fileHash fileURI)
            $ whenComplete content
      else write chunk >> go (chunk : acc)

-- | Lower a Sem action into IO so it can run after the request handler has returned.
deferToIO :: Member (Final IO) r => (x -> Sem r ()) -> Sem r (x -> IO ())
deferToIO k = withWeavingToFinal $ \s lower _ -> pure ((\x -> void (lower (k x <$ s))) <$ s)