packages feed

hs-hath-1.1.1: src/Storage/R2.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module Storage.R2 ( R2Connection(..), mkR2Connection, runCacheR2 ) where

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

import qualified Conduit                 as C

import           Control.Exception       ( try )

import qualified Data.ByteArray          as BA
import qualified Data.ByteString         as BS
import           Data.Cache.LRU.IO
import qualified Data.Cache.LRU.IO       as LRU
import qualified Data.Text               as T

import           GHC.Clock               ( getMonotonicTime )

import           Network.HTTP.Client     ( newManager )
import           Network.HTTP.Client.TLS ( tlsManagerSettings )
import           Network.Minio           ( AccessKey(..)
                                         , CredentialValue(..)
                                         , SecretKey(..)
                                         , defaultPutObjectOptions
                                         , presignedGetObjectUrl
                                         , removeObject
                                         , setCreds
                                         , setRegion
                                         )
import qualified Network.Minio           as Minio
import           Network.Minio.S3API     ( headObject )

import           Polysemy
import           Polysemy.Error          ( Error, throw )
import           Polysemy.KVStore        ( KVStore(..) )
import           Polysemy.Operators
import           Polysemy.Reader         ( Reader, asks )

import           Relude                  hiding ( Reader, ask, asks )

import           Storage.Database        ( FileRecord(..) )

import           Types                   ( ClientConfig
                                         , FileURI(..)
                                         , RPCError(..)
                                         , StorageResult(..)
                                         )
import qualified Types                   as Ty

import           Utils                   ( log )

-- | R2 connection configuration built at runtime
data R2Connection = R2Connection { r2MinioConn :: !Minio.MinioConn, r2ConnBucket :: !Text }

-- | Build R2 connection from config + environment variables
-- Reads R2_ACCESS_KEY and R2_SECRET_KEY from environment
mkR2Connection :: Ty.R2Config -> IO (Either Text R2Connection)
mkR2Connection cfg = do
  accessKeyMay <- lookupEnv "R2_ACCESS_KEY"
  secretKeyMay <- lookupEnv "R2_SECRET_KEY"
  case ( accessKeyMay, secretKeyMay ) of
    ( Nothing, _ ) -> pure $ Left "R2_ACCESS_KEY environment variable not set"
    ( _, Nothing ) -> pure $ Left "R2_SECRET_KEY environment variable not set"
    ( Just accessKey, Just secretKey ) -> do
      let creds
            = CredentialValue
            { cvAccessKey    = AccessKey $ T.pack accessKey
            , cvSecretKey    = SecretKey $ BA.convert (encodeUtf8 @String @ByteString secretKey)
            , cvSessionToken = Nothing
            }
          -- Use IsString instance to parse endpoint URL
          baseConnInfo = fromString $ T.unpack $ Ty.r2Endpoint cfg
      -- Set credentials and region
      minioConn <- liftIO $ do
        manager <- newManager tlsManagerSettings
        Minio.mkMinioConn (setCreds creds $ setRegion "auto" baseConnInfo) manager
      pure $ Right R2Connection { r2MinioConn = minioConn, r2ConnBucket = Ty.r2Bucket cfg }

-- | Generate R2 object key from FileURI
-- Format: s4/hash-size-xres-yres-ext
fileURIToKey :: FileURI -> Text
fileURIToKey uri = s4 <> "/" <> fileId
  where
    fileId = show uri

    s4     = T.take 4 fileId

-- | Run the cache with Cloudflare R2
-- Read operations silently degrade to Nothing on failure (with warning log)
-- Write/delete operations propagate errors
runCacheR2 :: Members '[ Embed IO, Log Message, Reader ClientConfig, Error RPCError ] r
           => R2Connection
           -> KVStore FileURI StorageResult : r @> a
           -> r @> a
runCacheR2 conn m = do
  lruSize <- asks Ty.lruCacheSize
  let entries = Just (fromIntegral (lruSize `div` 300)) -- Approximate number of entries based on avg url link size ~300 bytes
  memCache <- embed $ newAtomicLRU entries
  interpret (phi memCache) m
  where
    bucket = r2ConnBucket conn

    phi :: forall r r1 x. Members '[ Embed IO, Log Message, Error RPCError ] r1
        => AtomicLRU FileURI ( Double, ByteString )
        -> KVStore FileURI StorageResult (Sem r) x
        -> Sem r1 x
    phi cache (LookupKV uri@(fileURIToKey -> key))         = do
      now <- embed getMonotonicTime
      embed (LRU.lookup uri cache) >>= \case
        Just ( expireTime, link )
          | now < expireTime -> pure $ Just $ Redirect link
        _ -> do
          -- First, check if object exists using HEAD request
          headResult <- embed
            $ try @SomeException
            $ Minio.runMinioWith (r2MinioConn conn)
            $ headObject bucket key []
          case headResult of
            Right (Right _info) -> do
              -- Object exists, try to generate presigned URL (1200 seconds expiry = 20 mins)
              maybeLink <- embed
                (Minio.runMinioWith
                   (r2MinioConn conn)
                   (presignedGetObjectUrl bucket key 1200 [] []))
              case maybeLink of
                Left _minioErr -> pure Nothing
                Right link     -> do
                  let expireTime = now + 600 -- 10 minutes cache TTL to ensure fresh links
                  embed $ LRU.insert uri ( expireTime, link ) cache
                  pure $ Just $ Redirect link
            _ -> do
              -- Object does not exist in R2 storage
              log Warning ("Object not found in R2 storage: " <> key)
              pure Nothing

    phi _cache (UpdateKV _uri (Just (Redirect _url)))
      = error "impossible: cannot store Redirect in R2 backend"

    phi _cache (UpdateKV uri (Just (Record record)))       = do
      let key     = fileURIToKey uri
          content = fileRecordBytes record
          siz     = fromIntegral (BS.length content) :: Int64
          src     = C.yield content
      result <- embed
        $ try @SomeException
        $ Minio.runMinioWith (r2MinioConn conn)
        $ Minio.putObject bucket key src (Just siz) defaultPutObjectOptions
      case result of
        Left err -> do
          log Error ("R2 write failed for " <> key <> ": " <> T.pack (show err))
          throw $ Ty.StorageError (T.pack $ show err)
        Right (Left minioErr) -> do
          log Error ("R2 write failed for " <> key <> ": " <> T.pack (show minioErr))
          throw $ Ty.StorageError (T.pack $ show minioErr)
        Right (Right _) -> pure ()

    phi cache (UpdateKV uri@(fileURIToKey -> key) Nothing) = do
      result <- embed
        $ try @SomeException
        $ Minio.runMinioWith (r2MinioConn conn)
        $ removeObject bucket key
      case result of
        Left err -> do
          log Error ("R2 delete failed for " <> key <> ": " <> T.pack (show err))
          throw $ Ty.StorageError (T.pack $ show err)
        Right (Left minioErr) -> do
          log Error ("R2 delete failed for " <> key <> ": " <> T.pack (show minioErr))
          throw $ Ty.StorageError (T.pack $ show minioErr)
        Right (Right _) -> embed $ void $ LRU.delete uri cache