Use region specific endpoints for AWS S3 in presigned Urls (#164)
- Also update standard S3 endpoints - Unify code that determines if path style or virtual style must be used for regular and presigned requests Fixes #160
This commit is contained in:
parent
b8cc1e57ee
commit
aa2382b2e9
@ -77,6 +77,7 @@ common base-settings
|
|||||||
, http-types >= 0.12
|
, http-types >= 0.12
|
||||||
, ini
|
, ini
|
||||||
, memory >= 0.14
|
, memory >= 0.14
|
||||||
|
, network-uri
|
||||||
, raw-strings-qq >= 1
|
, raw-strings-qq >= 1
|
||||||
, resourcet >= 1.2
|
, resourcet >= 1.2
|
||||||
, retry
|
, retry
|
||||||
|
|||||||
@ -19,6 +19,7 @@ module Network.Minio.API
|
|||||||
S3ReqInfo (..),
|
S3ReqInfo (..),
|
||||||
runMinio,
|
runMinio,
|
||||||
executeRequest,
|
executeRequest,
|
||||||
|
buildRequest,
|
||||||
mkStreamRequest,
|
mkStreamRequest,
|
||||||
getLocation,
|
getLocation,
|
||||||
isValidBucketName,
|
isValidBucketName,
|
||||||
@ -40,6 +41,7 @@ import qualified Data.HashMap.Strict as H
|
|||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Time.Clock as Time
|
import qualified Data.Time.Clock as Time
|
||||||
import Lib.Prelude
|
import Lib.Prelude
|
||||||
|
import qualified Network.HTTP.Client as NClient
|
||||||
import Network.HTTP.Conduit (Response)
|
import Network.HTTP.Conduit (Response)
|
||||||
import qualified Network.HTTP.Conduit as NC
|
import qualified Network.HTTP.Conduit as NC
|
||||||
import qualified Network.HTTP.Types as HT
|
import qualified Network.HTTP.Types as HT
|
||||||
@ -78,6 +80,7 @@ discoverRegion ri = runMaybeT $ do
|
|||||||
return
|
return
|
||||||
regionMay
|
regionMay
|
||||||
|
|
||||||
|
-- | Returns the region to be used for the request.
|
||||||
getRegion :: S3ReqInfo -> Minio (Maybe Region)
|
getRegion :: S3ReqInfo -> Minio (Maybe Region)
|
||||||
getRegion ri = do
|
getRegion ri = do
|
||||||
ci <- asks mcConnInfo
|
ci <- asks mcConnInfo
|
||||||
@ -104,6 +107,42 @@ getRegionHost r = do
|
|||||||
(H.lookup r awsRegionMap)
|
(H.lookup r awsRegionMap)
|
||||||
else return $ connectHost ci
|
else return $ connectHost ci
|
||||||
|
|
||||||
|
-- | Computes the appropriate host, path and region for the request.
|
||||||
|
--
|
||||||
|
-- For AWS, always use virtual bucket style, unless bucket has periods. For
|
||||||
|
-- MinIO and other non-AWS, default to path style.
|
||||||
|
getHostPathRegion :: S3ReqInfo -> Minio (Text, ByteString, Maybe Region)
|
||||||
|
getHostPathRegion ri = do
|
||||||
|
ci <- asks mcConnInfo
|
||||||
|
regionMay <- getRegion ri
|
||||||
|
case riBucket ri of
|
||||||
|
Nothing ->
|
||||||
|
-- Implies a ListBuckets request.
|
||||||
|
return (connectHost ci, "/", regionMay)
|
||||||
|
Just bucket -> do
|
||||||
|
regionHost <- case regionMay of
|
||||||
|
Nothing -> return $ connectHost ci
|
||||||
|
Just "" -> return $ connectHost ci
|
||||||
|
Just r -> getRegionHost r
|
||||||
|
let pathStyle =
|
||||||
|
( regionHost,
|
||||||
|
getS3Path (riBucket ri) (riObject ri),
|
||||||
|
regionMay
|
||||||
|
)
|
||||||
|
virtualStyle =
|
||||||
|
( ( bucket <> "." <> regionHost,
|
||||||
|
encodeUtf8 $ "/" <> fromMaybe "" (riObject ri),
|
||||||
|
regionMay
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if
|
||||||
|
| isAWSConnectInfo ci ->
|
||||||
|
return $
|
||||||
|
if bucketHasPeriods bucket
|
||||||
|
then pathStyle
|
||||||
|
else virtualStyle
|
||||||
|
| otherwise -> return pathStyle
|
||||||
|
|
||||||
buildRequest :: S3ReqInfo -> Minio NC.Request
|
buildRequest :: S3ReqInfo -> Minio NC.Request
|
||||||
buildRequest ri = do
|
buildRequest ri = do
|
||||||
maybe (return ()) checkBucketNameValidity $ riBucket ri
|
maybe (return ()) checkBucketNameValidity $ riBucket ri
|
||||||
@ -111,17 +150,15 @@ buildRequest ri = do
|
|||||||
|
|
||||||
ci <- asks mcConnInfo
|
ci <- asks mcConnInfo
|
||||||
|
|
||||||
regionMay <- getRegion ri
|
(host, path, regionMay) <- getHostPathRegion ri
|
||||||
|
|
||||||
regionHost <- maybe (return $ connectHost ci) getRegionHost regionMay
|
let ci' = ci {connectHost = host}
|
||||||
|
hostHeader = (hHost, getHostAddr ci')
|
||||||
let ri' =
|
ri' =
|
||||||
ri
|
ri
|
||||||
{ riHeaders = hostHeader : riHeaders ri,
|
{ riHeaders = hostHeader : riHeaders ri,
|
||||||
riRegion = regionMay
|
riRegion = regionMay
|
||||||
}
|
}
|
||||||
ci' = ci {connectHost = regionHost}
|
|
||||||
hostHeader = (hHost, getHostAddr ci')
|
|
||||||
-- Does not contain body and auth info.
|
-- Does not contain body and auth info.
|
||||||
baseRequest =
|
baseRequest =
|
||||||
NC.defaultRequest
|
NC.defaultRequest
|
||||||
@ -129,7 +166,7 @@ buildRequest ri = do
|
|||||||
NC.secure = connectIsSecure ci',
|
NC.secure = connectIsSecure ci',
|
||||||
NC.host = encodeUtf8 $ connectHost ci',
|
NC.host = encodeUtf8 $ connectHost ci',
|
||||||
NC.port = connectPort ci',
|
NC.port = connectPort ci',
|
||||||
NC.path = getS3Path (riBucket ri') (riObject ri'),
|
NC.path = path,
|
||||||
NC.requestHeaders = riHeaders ri',
|
NC.requestHeaders = riHeaders ri',
|
||||||
NC.queryString = HT.renderQuery False $ riQueryParams ri'
|
NC.queryString = HT.renderQuery False $ riQueryParams ri'
|
||||||
}
|
}
|
||||||
@ -142,11 +179,13 @@ buildRequest ri = do
|
|||||||
(connectSecretKey ci')
|
(connectSecretKey ci')
|
||||||
timeStamp
|
timeStamp
|
||||||
(riRegion ri')
|
(riRegion ri')
|
||||||
Nothing
|
(riPresignExpirySecs ri')
|
||||||
Nothing
|
Nothing
|
||||||
|
|
||||||
-- Cases to handle:
|
-- Cases to handle:
|
||||||
--
|
--
|
||||||
|
-- 0. Handle presign URL case.
|
||||||
|
--
|
||||||
-- 1. Connection is secure: use unsigned payload
|
-- 1. Connection is secure: use unsigned payload
|
||||||
--
|
--
|
||||||
-- 2. Insecure connection, streaming signature is enabled via use of
|
-- 2. Insecure connection, streaming signature is enabled via use of
|
||||||
@ -155,33 +194,44 @@ buildRequest ri = do
|
|||||||
-- 3. Insecure connection, non-conduit payload: compute payload
|
-- 3. Insecure connection, non-conduit payload: compute payload
|
||||||
-- sha256hash, buffer request in memory and perform request.
|
-- sha256hash, buffer request in memory and perform request.
|
||||||
|
|
||||||
-- case 2 from above.
|
|
||||||
if
|
if
|
||||||
| isStreamingPayload (riPayload ri')
|
| isJust (riPresignExpirySecs ri') ->
|
||||||
&& (not $ connectIsSecure ci') -> do
|
-- case 0 from above.
|
||||||
(pLen, pSrc) <- case riPayload ri of
|
do
|
||||||
PayloadC l src -> return (l, src)
|
let signPairs = signV4 sp baseRequest
|
||||||
_ -> throwIO MErrVUnexpectedPayload
|
qpToAdd = (fmap . fmap) Just signPairs
|
||||||
let reqFn = signV4Stream pLen sp baseRequest
|
existingQueryParams = HT.parseQuery (NC.queryString baseRequest)
|
||||||
return $ reqFn pSrc
|
updatedQueryParams = existingQueryParams ++ qpToAdd
|
||||||
| otherwise -> do
|
return $ NClient.setQueryString updatedQueryParams baseRequest
|
||||||
-- case 1 described above.
|
| isStreamingPayload (riPayload ri') && (not $ connectIsSecure ci') ->
|
||||||
sp' <-
|
-- case 2 from above.
|
||||||
if
|
do
|
||||||
| connectIsSecure ci' -> return sp
|
(pLen, pSrc) <- case riPayload ri of
|
||||||
-- case 3 described above.
|
PayloadC l src -> return (l, src)
|
||||||
| otherwise -> do
|
_ -> throwIO MErrVUnexpectedPayload
|
||||||
pHash <- getPayloadSHA256Hash $ riPayload ri'
|
let reqFn = signV4Stream pLen sp baseRequest
|
||||||
return $ sp {spPayloadHash = Just pHash}
|
return $ reqFn pSrc
|
||||||
|
| otherwise ->
|
||||||
|
do
|
||||||
|
sp' <-
|
||||||
|
if
|
||||||
|
| connectIsSecure ci' ->
|
||||||
|
-- case 1 described above.
|
||||||
|
return sp
|
||||||
|
| otherwise ->
|
||||||
|
-- case 3 described above.
|
||||||
|
do
|
||||||
|
pHash <- getPayloadSHA256Hash $ riPayload ri'
|
||||||
|
return $ sp {spPayloadHash = Just pHash}
|
||||||
|
|
||||||
let signHeaders = signV4 sp' baseRequest
|
let signHeaders = signV4 sp' baseRequest
|
||||||
return $
|
return $
|
||||||
baseRequest
|
baseRequest
|
||||||
{ NC.requestHeaders =
|
{ NC.requestHeaders =
|
||||||
NC.requestHeaders baseRequest
|
NC.requestHeaders baseRequest
|
||||||
++ mkHeaderFromPairs signHeaders,
|
++ mkHeaderFromPairs signHeaders,
|
||||||
NC.requestBody = getRequestBody (riPayload ri')
|
NC.requestBody = getRequestBody (riPayload ri')
|
||||||
}
|
}
|
||||||
|
|
||||||
retryAPIRequest :: Minio a -> Minio a
|
retryAPIRequest :: Minio a -> Minio a
|
||||||
retryAPIRequest apiCall = do
|
retryAPIRequest apiCall = do
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import qualified Conduit as C
|
|||||||
import qualified Data.ByteString as BS
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.ByteString.Lazy as LB
|
import qualified Data.ByteString.Lazy as LB
|
||||||
import Data.Conduit.Binary (sourceHandleRange)
|
import Data.Conduit.Binary (sourceHandleRange)
|
||||||
|
import qualified Data.Text as T
|
||||||
import Lib.Prelude
|
import Lib.Prelude
|
||||||
import qualified Network.HTTP.Conduit as NC
|
import qualified Network.HTTP.Conduit as NC
|
||||||
import qualified Network.HTTP.Types as HT
|
import qualified Network.HTTP.Types as HT
|
||||||
@ -70,3 +71,10 @@ mkStreamingPayload payload =
|
|||||||
isStreamingPayload :: Payload -> Bool
|
isStreamingPayload :: Payload -> Bool
|
||||||
isStreamingPayload (PayloadC _ _) = True
|
isStreamingPayload (PayloadC _ _) = True
|
||||||
isStreamingPayload _ = False
|
isStreamingPayload _ = False
|
||||||
|
|
||||||
|
-- | Checks if the connect info is for Amazon S3.
|
||||||
|
isAWSConnectInfo :: ConnectInfo -> Bool
|
||||||
|
isAWSConnectInfo ci = ".amazonaws.com" `T.isSuffixOf` connectHost ci
|
||||||
|
|
||||||
|
bucketHasPeriods :: Bucket -> Bool
|
||||||
|
bucketHasPeriods b = isJust $ T.find (== '.') b
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import Network.HTTP.Types
|
|||||||
)
|
)
|
||||||
import qualified Network.HTTP.Types as HT
|
import qualified Network.HTTP.Types as HT
|
||||||
import Network.Minio.Data.Crypto
|
import Network.Minio.Data.Crypto
|
||||||
|
import Network.Minio.Data.Time
|
||||||
import Network.Minio.Errors
|
import Network.Minio.Errors
|
||||||
import System.Directory (doesFileExist, getHomeDirectory)
|
import System.Directory (doesFileExist, getHomeDirectory)
|
||||||
import qualified System.Environment as Env
|
import qualified System.Environment as Env
|
||||||
@ -79,20 +80,20 @@ maxMultipartParts = 10000
|
|||||||
awsRegionMap :: H.HashMap Text Text
|
awsRegionMap :: H.HashMap Text Text
|
||||||
awsRegionMap =
|
awsRegionMap =
|
||||||
H.fromList
|
H.fromList
|
||||||
[ ("us-east-1", "s3.amazonaws.com"),
|
[ ("us-east-1", "s3.us-east-1.amazonaws.com"),
|
||||||
("us-east-2", "s3-us-east-2.amazonaws.com"),
|
("us-east-2", "s3.us-east-2.amazonaws.com"),
|
||||||
("us-west-1", "s3-us-west-1.amazonaws.com"),
|
("us-west-1", "s3.us-west-1.amazonaws.com"),
|
||||||
("us-west-2", "s3-us-west-2.amazonaws.com"),
|
("us-west-2", "s3.us-west-2.amazonaws.com"),
|
||||||
("ca-central-1", "s3-ca-central-1.amazonaws.com"),
|
("ca-central-1", "s3.ca-central-1.amazonaws.com"),
|
||||||
("ap-south-1", "s3-ap-south-1.amazonaws.com"),
|
("ap-south-1", "s3.ap-south-1.amazonaws.com"),
|
||||||
("ap-northeast-1", "s3-ap-northeast-1.amazonaws.com"),
|
("ap-northeast-1", "s3.ap-northeast-1.amazonaws.com"),
|
||||||
("ap-northeast-2", "s3-ap-northeast-2.amazonaws.com"),
|
("ap-northeast-2", "s3.ap-northeast-2.amazonaws.com"),
|
||||||
("ap-southeast-1", "s3-ap-southeast-1.amazonaws.com"),
|
("ap-southeast-1", "s3.ap-southeast-1.amazonaws.com"),
|
||||||
("ap-southeast-2", "s3-ap-southeast-2.amazonaws.com"),
|
("ap-southeast-2", "s3.ap-southeast-2.amazonaws.com"),
|
||||||
("eu-west-1", "s3-eu-west-1.amazonaws.com"),
|
("eu-west-1", "s3.eu-west-1.amazonaws.com"),
|
||||||
("eu-west-2", "s3-eu-west-2.amazonaws.com"),
|
("eu-west-2", "s3.eu-west-2.amazonaws.com"),
|
||||||
("eu-central-1", "s3-eu-central-1.amazonaws.com"),
|
("eu-central-1", "s3.eu-central-1.amazonaws.com"),
|
||||||
("sa-east-1", "s3-sa-east-1.amazonaws.com")
|
("sa-east-1", "s3.sa-east-1.amazonaws.com")
|
||||||
]
|
]
|
||||||
|
|
||||||
-- | Connection Info data type. To create a 'ConnectInfo' value,
|
-- | Connection Info data type. To create a 'ConnectInfo' value,
|
||||||
@ -1022,7 +1023,8 @@ data S3ReqInfo = S3ReqInfo
|
|||||||
riPayload :: Payload,
|
riPayload :: Payload,
|
||||||
riPayloadHash :: Maybe ByteString,
|
riPayloadHash :: Maybe ByteString,
|
||||||
riRegion :: Maybe Region,
|
riRegion :: Maybe Region,
|
||||||
riNeedsLocation :: Bool
|
riNeedsLocation :: Bool,
|
||||||
|
riPresignExpirySecs :: Maybe UrlExpiry
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultS3ReqInfo :: S3ReqInfo
|
defaultS3ReqInfo :: S3ReqInfo
|
||||||
@ -1037,16 +1039,13 @@ defaultS3ReqInfo =
|
|||||||
Nothing
|
Nothing
|
||||||
Nothing
|
Nothing
|
||||||
True
|
True
|
||||||
|
Nothing
|
||||||
|
|
||||||
getS3Path :: Maybe Bucket -> Maybe Object -> ByteString
|
getS3Path :: Maybe Bucket -> Maybe Object -> ByteString
|
||||||
getS3Path b o =
|
getS3Path b o =
|
||||||
let segments = map toUtf8 $ catMaybes $ b : bool [] [o] (isJust b)
|
let segments = map toUtf8 $ catMaybes $ b : bool [] [o] (isJust b)
|
||||||
in B.concat ["/", B.intercalate "/" segments]
|
in B.concat ["/", B.intercalate "/" segments]
|
||||||
|
|
||||||
-- | Time to expire for a presigned URL. It interpreted as a number of
|
|
||||||
-- seconds. The maximum duration that can be specified is 7 days.
|
|
||||||
type UrlExpiry = Int
|
|
||||||
|
|
||||||
type RegionMap = H.HashMap Bucket Region
|
type RegionMap = H.HashMap Bucket Region
|
||||||
|
|
||||||
-- | The Minio Monad - all computations accessing object storage
|
-- | The Minio Monad - all computations accessing object storage
|
||||||
|
|||||||
@ -21,6 +21,7 @@ module Network.Minio.Data.Time
|
|||||||
awsDateFormatBS,
|
awsDateFormatBS,
|
||||||
awsParseTime,
|
awsParseTime,
|
||||||
iso8601TimeFormat,
|
iso8601TimeFormat,
|
||||||
|
UrlExpiry,
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
@ -28,6 +29,10 @@ import Data.ByteString.Char8 (pack)
|
|||||||
import qualified Data.Time as Time
|
import qualified Data.Time as Time
|
||||||
import Lib.Prelude
|
import Lib.Prelude
|
||||||
|
|
||||||
|
-- | Time to expire for a presigned URL. It interpreted as a number of
|
||||||
|
-- seconds. The maximum duration that can be specified is 7 days.
|
||||||
|
type UrlExpiry = Int
|
||||||
|
|
||||||
awsTimeFormat :: UTCTime -> [Char]
|
awsTimeFormat :: UTCTime -> [Char]
|
||||||
awsTimeFormat = Time.formatTime Time.defaultTimeLocale "%Y%m%dT%H%M%SZ"
|
awsTimeFormat = Time.formatTime Time.defaultTimeLocale "%Y%m%dT%H%M%SZ"
|
||||||
|
|
||||||
|
|||||||
@ -42,13 +42,14 @@ import qualified Data.HashMap.Strict as H
|
|||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Time as Time
|
import qualified Data.Time as Time
|
||||||
import Lib.Prelude
|
import Lib.Prelude
|
||||||
import qualified Network.HTTP.Conduit as NC
|
import qualified Network.HTTP.Client as NClient
|
||||||
import qualified Network.HTTP.Types as HT
|
import qualified Network.HTTP.Types as HT
|
||||||
import Network.HTTP.Types.Header (hHost)
|
import Network.Minio.API (buildRequest)
|
||||||
import Network.Minio.Data
|
import Network.Minio.Data
|
||||||
import Network.Minio.Data.Time
|
import Network.Minio.Data.Time
|
||||||
import Network.Minio.Errors
|
import Network.Minio.Errors
|
||||||
import Network.Minio.Sign.V4
|
import Network.Minio.Sign.V4
|
||||||
|
import Network.URI (uriToString)
|
||||||
|
|
||||||
-- | Generate a presigned URL. This function allows for advanced usage
|
-- | Generate a presigned URL. This function allows for advanced usage
|
||||||
-- - for simple cases prefer the `presigned*Url` functions.
|
-- - for simple cases prefer the `presigned*Url` functions.
|
||||||
@ -72,44 +73,22 @@ makePresignedUrl expiry method bucket object region extraQuery extraHeaders = do
|
|||||||
throwIO $
|
throwIO $
|
||||||
MErrVInvalidUrlExpiry expiry
|
MErrVInvalidUrlExpiry expiry
|
||||||
|
|
||||||
ci <- asks mcConnInfo
|
let s3ri =
|
||||||
|
defaultS3ReqInfo
|
||||||
let hostHeader = (hHost, getHostAddr ci)
|
{ riPresignExpirySecs = Just expiry,
|
||||||
req =
|
riMethod = method,
|
||||||
NC.defaultRequest
|
riBucket = bucket,
|
||||||
{ NC.method = method,
|
riObject = object,
|
||||||
NC.secure = connectIsSecure ci,
|
riRegion = region,
|
||||||
NC.host = encodeUtf8 $ connectHost ci,
|
riQueryParams = extraQuery,
|
||||||
NC.port = connectPort ci,
|
riHeaders = extraHeaders
|
||||||
NC.path = getS3Path bucket object,
|
|
||||||
NC.requestHeaders = hostHeader : extraHeaders,
|
|
||||||
NC.queryString = HT.renderQuery True extraQuery
|
|
||||||
}
|
}
|
||||||
ts <- liftIO Time.getCurrentTime
|
|
||||||
|
|
||||||
let sp =
|
req <- buildRequest s3ri
|
||||||
SignParams
|
let uri = NClient.getUri req
|
||||||
(connectAccessKey ci)
|
uriString = uriToString identity uri ""
|
||||||
(connectSecretKey ci)
|
|
||||||
ts
|
|
||||||
region
|
|
||||||
(Just expiry)
|
|
||||||
Nothing
|
|
||||||
signPairs = signV4 sp req
|
|
||||||
qpToAdd = (fmap . fmap) Just signPairs
|
|
||||||
queryStr =
|
|
||||||
HT.renderQueryBuilder
|
|
||||||
True
|
|
||||||
((HT.parseQuery $ NC.queryString req) ++ qpToAdd)
|
|
||||||
scheme = byteString $ bool "http://" "https://" $ connectIsSecure ci
|
|
||||||
|
|
||||||
return $
|
return $ toUtf8 uriString
|
||||||
toStrictBS $
|
|
||||||
toLazyByteString $
|
|
||||||
scheme
|
|
||||||
<> byteString (getHostAddr ci)
|
|
||||||
<> byteString (getS3Path bucket object)
|
|
||||||
<> queryStr
|
|
||||||
|
|
||||||
-- | Generate a URL with authentication signature to PUT (upload) an
|
-- | Generate a URL with authentication signature to PUT (upload) an
|
||||||
-- object. Any extra headers if passed, are signed, and so they are
|
-- object. Any extra headers if passed, are signed, and so they are
|
||||||
|
|||||||
@ -65,7 +65,7 @@ data SignParams = SignParams
|
|||||||
spSecretKey :: Text,
|
spSecretKey :: Text,
|
||||||
spTimeStamp :: UTCTime,
|
spTimeStamp :: UTCTime,
|
||||||
spRegion :: Maybe Text,
|
spRegion :: Maybe Text,
|
||||||
spExpirySecs :: Maybe Int,
|
spExpirySecs :: Maybe UrlExpiry,
|
||||||
spPayloadHash :: Maybe ByteString
|
spPayloadHash :: Maybe ByteString
|
||||||
}
|
}
|
||||||
deriving (Show)
|
deriving (Show)
|
||||||
|
|||||||
@ -34,7 +34,6 @@ import qualified Network.HTTP.Types as HT
|
|||||||
import Network.Minio
|
import Network.Minio
|
||||||
import Network.Minio.Data
|
import Network.Minio.Data
|
||||||
import Network.Minio.Data.Crypto
|
import Network.Minio.Data.Crypto
|
||||||
import Network.Minio.PutObject
|
|
||||||
import Network.Minio.S3API
|
import Network.Minio.S3API
|
||||||
import Network.Minio.Utils
|
import Network.Minio.Utils
|
||||||
import System.Directory (getTemporaryDirectory)
|
import System.Directory (getTemporaryDirectory)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user