do-spaces-0.2: src/Network/DO/Spaces.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
-- |
-- Module : Network.DO.Spaces
-- Copyright : (c) 2021 Rory Tyler Hayford
-- License : BSD-3-Clause
-- Maintainer : rory.hayford@protonmail.com
-- Stability : experimental
-- Portability : GHC
--
-- Interacting with DigitalOcean's Spaces API, a (largely) s3-compatible object
-- storage platform. This module exports actions to create a 'Spaces' client
-- configuration as well as several convenience actions. Most of the transactions
-- exposed through the Spaces REST API are supported here, including CRUD operations
-- on buckets and objects, bucket CORS configuration, and manipulating ACLs.
--
-- See the README in this repository for more information on using this library
--
module Network.DO.Spaces
( runSpaces
, newSpaces
-- * Convenience actions
-- $conv
-- ** Object operations
, uploadObject
, multipartObject
, uploadFile
, getObject
, getObjectSinkFile
, getObjectInfo
, copyObject
, copyObjectWithin
, overwriteObject
, deleteObject
, getObjectACLs
, setObjectACLs
-- ** Bucket operations
, createBucket
, deleteBucket
, getBucketLocation
, listAllBuckets
, listBucket
, listBucketGrouped
, listBucketRec
, getBucketCORS
, deleteBucketCORS
, setBucketCORS
, getBucketACLs
, setBucketACLs
, getBucketLifecycleRules
, setBucketLifecycleRules
, deleteBucketLifecycleRules
-- * Re-exports
, Spaces
, SpacesResponse
, SpacesMetadata
, MonadSpaces
, Bucket
, mkBucket
, Object
, mkObject
, Region(..)
, AccessKey(..)
, SecretKey(..)
, CredentialSource(..)
, Profile
, CORSRule
, mkCORSRule
, Grant
, Grantee(..)
, Permission(..)
, LifecycleID
, mkLifecycleID
, SpacesException
, ClientException(..)
, APIException
) where
import Conduit
import Control.Monad ( void )
import Control.Monad.Catch ( MonadCatch(catch) )
import Data.Bool ( bool )
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as LB
import Data.Coerce ( coerce )
import Data.Conduit.Binary ( sinkLbs )
import Data.Conduit.List ( consume )
import Data.Foldable ( asum )
import Data.Generics.Product ( HasField(field) )
import qualified Data.Ini.Config as I
import Data.Maybe ( fromMaybe )
import Data.Sequence ( Seq )
import qualified Data.Text as T
import Data.Text ( Text )
import qualified Data.Text.Lazy as LT
import Lens.Micro ( (<&>), (^.) )
import Network.DO.Spaces.Actions
import Network.DO.Spaces.Types
import Network.DO.Spaces.Utils
import Network.HTTP.Client.Conduit ( RequestBody(RequestBodyLBS) )
import Network.HTTP.Client.TLS ( getGlobalManager )
import Network.Mime
( MimeType
, defaultMimeMap
, defaultMimeType
, mimeByExt
)
import qualified System.FilePath as F
import UnliftIO.Directory
import UnliftIO.Environment
-- | Perform a transaction using your 'Spaces' client configuration. Note that
-- this does not perform any exception handling; if caught at the lower level,
-- exceptions are generally re-thrown as 'SpacesException's
runSpaces :: Spaces -> SpacesT m a -> m a
runSpaces = flip runSpacesT
-- | Create a new 'Spaces' by specifying a method to retrieve the region and
-- your credentials.
--
-- 'Discover' will first try to find a credentials file (see the notes on 'FromFile'
-- below) in @~/.aws/credentials@ or @$XDG_CONFIG_HOME/do-spaces/credentials@, in
-- that order, using the @[default]@ profile. Failing that, it will try the equivalent
-- of @FromEnv Nothing@ (see the notes below).
--
-- 'FromFile' expects a configuration file in the same format as AWS credentials
-- files, with the same field names. For example:
--
-- > [default]
-- > aws_access_key_id=AKIAIOSFODNN7EXAMPLE
-- > aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
-- > aws_default_region=fra1
--
-- When provided with @Nothing@, 'FromEnv' will look up the following environment
-- variables to find your region and keys:
--
-- * For the 'Region':
--
-- - @AWS_DEFAULT_REGION@
-- - /OR/ @SPACES_DEFAULT_REGION@
--
-- * For the 'AccessKey':
--
-- - @AWS_ACCESS_KEY_ID@
-- - /OR/ @SPACES_ACCESS_KEY_ID@
-- - /OR/ @SPACES_ACCESS_KEY@
--
-- * For the 'SecretKey':
--
-- - @AWS_SECRET_ACCESS_KEY@
-- - /OR/ @SPACES_SECRET_ACCESS_KEY@
-- - /OR/ @SPACES_SECRET_KEY@
--
-- Alternatively, you can directly specify a tuple of environment variables to
-- search for.
--
-- You can also choose to provide the region and both keys yourself with 'Explicit'
--
newSpaces :: (MonadThrow m, MonadIO m) => CredentialSource -> m Spaces
newSpaces cs = do
manager <- liftIO getGlobalManager
(region, accessKey, secretKey) <- liftIO $ source cs
pure Spaces { .. }
source :: (MonadIO m, MonadCatch m)
=> CredentialSource
-> m (Region, AccessKey, SecretKey)
source = \case
Discover -> catch @_ @ClientException tryFile $ const tryEnv
where
tryFile = do
cfgDir <- getXdgDirectory XdgConfig "do-spaces"
findFile [ ".aws", cfgDir ] "credentials" >>= \case
Nothing ->
throwM $ ConfigurationError "No credentials file found"
Just fp -> source $ FromFile fp Nothing
tryEnv = source $ FromEnv Nothing
Explicit region ak sk -> pure (region, ak, sk)
FromEnv (Just (region, a, s)) -> ensureVars
=<< (,,) <$> lookupVar region <*> lookupVar a <*> lookupVar s
FromEnv Nothing -> do
region <- lookupVars [ "AWS_DEFAULT_REGION", "SPACES_DEFAULT_REGION" ]
ak <- lookupVars [ "AWS_ACCESS_KEY_ID"
, "SPACES_ACCESS_KEY_ID"
, "SPACES_ACCESS_KEY"
]
sk <- lookupVars [ "AWS_SECRET_ACCESS_KEY"
, "SPACES_SECRET_ACCESS_KEY"
, "SPACES_SECRET_KEY"
]
ensureVars (region, ak, sk)
FromFile fp profile -> do
contents <- LT.toStrict
<$> liftIO (runConduitRes (sourceFile fp
.| decodeUtf8C
.| sinkLazy))
either (throwM . ConfigurationError . T.pack) ensureVars
$ I.parseIniFile contents parseConf
where
parseConf = I.section (fromMaybe "default" profile)
$ (,,)
<$> (fmap (T.unpack . T.toLower)
<$> I.fieldMb "aws_default_region")
<*> (fmap T.unpack <$> I.fieldMb "aws_access_key_id")
<*> (fmap T.unpack <$> I.fieldMb "aws_secret_access_key")
where
lookupVars xs = asum <$> sequence (lookupEnv <$> xs)
lookupVar = lookupEnv . T.unpack
ensureVars = \case
(Just r, Just a, Just s) -> do
reg <- slugToRegion r
pure (reg, coerce $ C.pack a, coerce $ C.pack s)
(_, _, _) -> throwM . ConfigurationError
$ "Missing acces/secret keys and/or region"
-- | Upload an 'Object' within a single request
uploadObject :: MonadSpaces m
=> Maybe MimeType
-> Bucket
-> Object
-> BodyBS m
-> m (SpacesResponse UploadObject)
uploadObject contentType bucket object rbody = do
body <- RequestBodyLBS <$> runConduit (rbody .| sinkLbs)
runAction KeepMetadata
UploadObject { optionalHeaders = defaultUploadHeaders, .. }
-- | Initiate and complete a multipart upload, using default 'UploadHeaders'.
-- If a 'SpacesException' is thrown while performing the transaction, an attempt
-- will be made to run a 'CancelMultipart' request, and the exception will be
-- rethrown
multipartObject
:: MonadSpaces m
=> Maybe MimeType
-> Bucket
-> Object
-> Int
-> BodyBS m
-> m (SpacesResponse CompleteMultipart)
multipartObject contentType bucket object size body
| size < 5242880 = throwM
$ InvalidRequest "multipartObject: Chunk size must be greater than/equal to 5MB"
| otherwise = do
session <- beginMultipart <&> (^. field @"result" . field @"session")
catch @_ @SpacesException (run session) $ \e -> do
void . runAction NoMetadata $ CancelMultipart session
throwM e
where
run session = completeMultipart session
=<< runConduit (body .| inChunks .| putPart session .| consume)
beginMultipart = runAction NoMetadata
BeginMultipart
{ optionalHeaders = defaultUploadHeaders, .. }
completeMultipart session tags = runAction KeepMetadata
$ CompleteMultipart session (zip [ 1 .. ] tags)
putPart session = go 1
where
go !n = awaitForever $ \v -> do
let pieceBody = RequestBodyLBS v
uploadPart =
runAction NoMetadata $ UploadPart session n pieceBody
etag <- lift $ uploadPart <&> (^. field @"result" . field @"etag")
yield etag >> go (n + 1)
inChunks = loop 0 []
where
loop !n chunk = await >>= maybe (yieldChunk chunk) go
where
go bs = bool (loop len newChunk)
(yieldChunk newChunk >> loop 0 [])
(size <= len)
where
len = C.length bs + n
newChunk = bs : chunk
yieldChunk = yield . LB.fromChunks . reverse
-- | Upload a file's contents as an 'Object'. This will attempt to set the
-- correct 'Network.Mime.MimeType' based on the file extension
uploadFile :: forall m.
MonadSpaces m
=> Bucket
-> Object
-> FilePath
-> m (SpacesResponse UploadObject)
uploadFile bucket object fp = withSourceFile @_ @m fp $ \body ->
uploadObject (Just mtype) bucket object body
where
mtype =
mimeByExt defaultMimeMap defaultMimeType . T.pack $ F.takeFileName fp
-- | Get information about an 'Object' (does not retrieve the body of the object)
getObjectInfo
:: MonadSpaces m => Bucket -> Object -> m (SpacesResponse GetObjectInfo)
getObjectInfo bucket object = runAction KeepMetadata GetObjectInfo { .. }
-- | Get an 'Object' (retrieves the actual body of the object)
getObject :: MonadSpaces m => Bucket -> Object -> m (SpacesResponse GetObject)
getObject bucket object = runAction KeepMetadata GetObject { .. }
-- | Get an 'Object'\'s data and write it to the provided 'FilePath'
getObjectSinkFile :: MonadSpaces m => Bucket -> Object -> FilePath -> m ()
getObjectSinkFile bucket object fp = do
objectData <- getObject bucket object
<&> (^. field @"result" . field @"objectData")
runConduitRes $ sourceLazy objectData .| sinkFileCautious fp
-- | Copy an 'Object' from one 'Bucket' to another; this chooses a number of
-- defaults to represent the most common cases and avoid a preponderance of
-- parameters. 'Object's are copied using default ACLs with the COPY metadata
-- directive.
--
-- If you'd like to use a specfic 'CannedACL' or 'MetadataDirective', use
-- 'CopyObject' directly with 'runAction'
copyObject :: MonadSpaces m
=> Bucket -- ^ Source 'Bucket'
-> Bucket -- ^ Destination 'Bucket'
-> Object -- ^ Source 'Object'
-> Object -- ^ Destination 'Object'
-> m (SpacesResponse CopyObject)
copyObject srcBucket destBucket srcObject destObject =
runAction KeepMetadata CopyObject { .. }
where
acl = Nothing
metadataDirective = Copy
-- | Copy an 'Object' within the same 'Bucket', using defaults for the
-- 'MetadataDirective' and 'CannedACL'
copyObjectWithin :: MonadSpaces m
=> Bucket
-> Object -- ^ Source 'Object'
-> Object -- ^ Destination 'Object'
-> m (SpacesResponse CopyObject)
copyObjectWithin srcBucket srcObject destObject =
runAction KeepMetadata CopyObject { .. }
where
acl = Nothing
metadataDirective = Copy
destBucket = srcBucket
-- | Copy an 'Object' to itself, overwriting its associated metadata
overwriteObject
:: MonadSpaces m => Bucket -> Object -> m (SpacesResponse CopyObject)
overwriteObject srcBucket srcObject = runAction KeepMetadata CopyObject { .. }
where
acl = Nothing
metadataDirective = Copy
destBucket = srcBucket
destObject = srcObject
-- | Delete a single 'Object'
deleteObject
:: MonadSpaces m => Bucket -> Object -> m (SpacesResponse DeleteObject)
deleteObject bucket object = runAction KeepMetadata DeleteObject { .. }
-- | Get an 'Object'\'s Access Control Lists
getObjectACLs
:: MonadSpaces m => Bucket -> Object -> m (SpacesResponse GetObjectACLs)
getObjectACLs bucket object = runAction KeepMetadata GetObjectACLs { .. }
-- | Set an 'Object'\'s Access Control Lists
setObjectACLs :: MonadSpaces m
=> Bucket
-> Object
-> Owner
-> [Grant]
-> m (SpacesResponse SetObjectACLs)
setObjectACLs bucket object owner acls =
runAction KeepMetadata SetObjectACLs { .. }
-- | Create a new 'Bucket'
createBucket :: MonadSpaces m
=> Bucket
-> Maybe Region -- ^ Overrides the 'Region' in your 'Spaces'
-- configuration
-> Maybe CannedACL
-> m (SpacesResponse CreateBucket)
createBucket bucket region acl = runAction KeepMetadata CreateBucket { .. }
-- | Delete a 'Bucket'
deleteBucket :: MonadSpaces m => Bucket -> m (SpacesResponse DeleteBucket)
deleteBucket bucket = runAction KeepMetadata DeleteBucket { .. }
-- | Get the location ('Region') of a 'Bucket'
getBucketLocation
:: MonadSpaces m => Bucket -> m (SpacesResponse GetBucketLocation)
getBucketLocation bucket = runAction KeepMetadata GetBucketLocation { .. }
-- | List every 'Bucket' associated with your Spaces account
listAllBuckets :: MonadSpaces m => m (SpacesResponse ListAllBuckets)
listAllBuckets = runAction KeepMetadata ListAllBuckets
-- | List the 'Object's of a 'Bucket', without grouping, delimiting, or limiting
-- the keys (i.e. list all 'Object's non-hierarchically, up to the Spaces limit)
listBucket :: MonadSpaces m => Bucket -> m (SpacesResponse ListBucket)
listBucket bucket = runAction KeepMetadata ListBucket { .. }
where
delimiter = Nothing
marker = Nothing
maxKeys = Nothing
prefix = Nothing
-- | List the 'Object's of a 'Bucket', using a delimiter and prefix to group
-- objects. For example @\/@ can be used as a delimiter to treat objects as
-- directories within the bucket, which can further be combined with a text
-- prefix
listBucketGrouped :: MonadSpaces m
=> Bucket
-> Char -- ^ Delimiter
-> Text -- ^ Prefix used to group object keys
-> m (SpacesResponse ListBucket)
listBucketGrouped bucket delimiter prefix =
runAction KeepMetadata
ListBucket
{ delimiter = Just delimiter, prefix = Just prefix, .. }
where
maxKeys = Nothing
marker = Nothing
-- | Recursively list /all/ 'Object's in a 'Bucket', calling 'ListBucket' until
-- @isTruncated@ is @False@. This operation may take some time, depending on the
-- total number of objects in your bucket
listBucketRec :: MonadSpaces m => Bucket -> m (Seq ObjectInfo)
listBucketRec bucket = go mempty Nothing
where
go os marker = do
listed <- runAction NoMetadata
$ ListBucket
{ delimiter = Nothing, maxKeys = Nothing, prefix = Nothing, .. }
let r = listed ^. field @"result"
isTruncated = r ^. field @"isTruncated"
objects = r ^. field @"objects"
nextMarker = r ^. field @"nextMarker"
case nextMarker of
Just _
| isTruncated -> go (os <> objects) nextMarker
| otherwise -> pure $ os <> objects
Nothing -> pure $ os <> objects
-- | Get the 'CORSRule's configured for a given 'Bucket'
getBucketCORS :: MonadSpaces m => Bucket -> m (SpacesResponse GetBucketCORS)
getBucketCORS bucket = runAction KeepMetadata $ GetBucketCORS { .. }
-- | Set 'CORSRule's for a given 'Bucket'
setBucketCORS :: MonadSpaces m
=> Bucket
-> [CORSRule]
-> m (SpacesResponse SetBucketCORS)
setBucketCORS bucket rules = runAction KeepMetadata $ SetBucketCORS { .. }
-- | Delete the existing configured 'CORSRule's for a given 'Bucket'
deleteBucketCORS
:: MonadSpaces m => Bucket -> m (SpacesResponse DeleteBucketCORS)
deleteBucketCORS bucket = runAction KeepMetadata $ DeleteBucketCORS { .. }
-- | Get a 'Bucket'\'s Access Control Lists
getBucketACLs :: MonadSpaces m => Bucket -> m (SpacesResponse GetBucketACLs)
getBucketACLs bucket = runAction KeepMetadata $ GetBucketACLs { .. }
-- | Set a 'Bucket'\'s Access Control Lists. Spaces only allows a limited subset
-- of s3 ACLs at the moment. It may be preferable to use a 'CannedACL' when
-- creating new resources rather than using this action, which is provided
-- for the sake of completeness.
--
-- Note that to allow public read-only access to your bucket, you /must/
-- simultaneously set full owner control.
setBucketACLs :: MonadSpaces m
=> Bucket
-> [Grant]
-> Owner
-> m (SpacesResponse SetBucketACLs)
setBucketACLs bucket acls owner =
runAction KeepMetadata $ SetBucketACLs { .. }
-- | Get a 'Bucket'\'s 'LifecycleRule' configuration . Note that unless you
-- have explicitly configured lifecycle rules, this will fail with a 404
-- status and an error code of @NoSuchLifecycleConfiguration@
getBucketLifecycleRules
:: MonadSpaces m => Bucket -> m (SpacesResponse GetBucketLifecycle)
getBucketLifecycleRules bucket =
runAction KeepMetadata $ GetBucketLifecycle { .. }
-- | Set a 'Bucket'\'s 'LifecycleRule' configuration
setBucketLifecycleRules :: MonadSpaces m
=> Bucket
-> [LifecycleRule]
-> m (SpacesResponse SetBucketLifecycle)
setBucketLifecycleRules bucket rules =
runAction KeepMetadata $ SetBucketLifecycle { .. }
-- | Delete a 'Bucket'\'s 'LifecycleRule' configuration
deleteBucketLifecycleRules
:: MonadSpaces m => Bucket -> m (SpacesResponse DeleteBucketLifecycle)
deleteBucketLifecycleRules bucket =
runAction KeepMetadata $ DeleteBucketLifecycle { .. }
--
-- $conv
-- The following are convenience actions. In most cases, each action is the same
-- as applying 'runAction' to a type that implements the 'Action' typeclass.
-- Information about the response is retained ('SpacesMetadata') in each action.
-- For instance:
--
-- > deleteBucket myBucket
--
-- is the equivalent of
--
-- > runAction KeepMetadata DeleteBucket { bucket = myBucket }
--
-- All of the underlying instances of 'Action' are exposed and can be imported from
-- "Network.DO.Spaces.Actions" and its sub-modules. The convenience actions exposed
-- in the present module attempt to choose sane defaults where applicable.
--
-- The only major exception to the above are actions which involve uploading object
-- data to Spaces. In the case of 'uploadObject', the action converts its 'BodyBS'
-- argument to a 'RequestBodyLBS'. Should you choose to directly construct
-- 'UploadObject', you must do this manually. 'multipartObject' is more complicated,
-- and takes care of chunking the request body, sending each individual request,
-- and completing the multipart request
--
-- In addition to convenience wrappers around 'Action' instances, this module exports
-- several actions which may be of use, including sinking remote 'Object' data into
-- a file, uploading the contents of a file as an 'Object', and recursively listing
-- the entire contents of a 'Bucket'
--