do-spaces-0.1.0: src/Network/DO/Spaces/Request.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
-- |
-- Module : Network.DO.Spaces.Request
-- Copyright : (c) 2021 Rory Tyler Hayford
-- License : BSD-3-Clause
-- Maintainer : rory.hayford@protonmail.com
-- Stability : experimental
-- Portability : GHC
--
-- Low-level implementations of Spaces REST transactions. You should not import
-- this module directly, but should instead use the higher-level interface exposed
-- by "Network.DO.Spaces.Actions" and its submodules
--
module Network.DO.Spaces.Request
( newSpacesRequest
, mkSignature
, mkStringToSign
, mkAuthorization
, finalize
) where
import Control.Monad.Catch ( MonadThrow )
import Crypto.Hash ( SHA256, hashlazy )
import Crypto.MAC.HMAC ( hmac )
import Data.Bifunctor ( first )
import Data.ByteArray ( convert )
import Data.ByteString ( ByteString )
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as LB
import qualified Data.CaseInsensitive as CI
import Data.Coerce ( coerce )
import Data.Function ( (&) )
import Data.Generics.Product ( HasField(field) )
import Data.Generics.Product.Positions ( HasPosition(position) )
import Data.List ( sort )
import Data.Maybe ( fromMaybe )
import qualified Data.Text as T
import Data.Time
( UTCTime
, defaultTimeLocale
, formatTime
)
import Lens.Micro ( (^.) )
import Network.DO.Spaces.Types
import Network.DO.Spaces.Utils
( bodyLBS
, regionSlug
, toLowerBS
)
import Network.HTTP.Client.Conduit ( Request
, RequestBody(RequestBodyLBS)
)
import qualified Network.HTTP.Client.Conduit as H
import Network.HTTP.Types ( Header, Query, QueryItem )
import qualified Network.HTTP.Types as H
-- | Extract the 'Request' from a 'SpacesRequest' and set the requisite
-- @Authorization@ header
finalize :: SpacesRequest -> Authorization -> Request
finalize sr auth = req { H.requestHeaders = authHeader : reqHeaders }
where
authHeader = (CI.mk "authorization", uncompute auth)
req = sr ^. field @"request"
reqHeaders = req & H.requestHeaders
-- | Create a new 'SpacesRequest' from a 'SpacesRequestBuilder', performing the
-- necessary computations and setting the appropriate default headers
newSpacesRequest
:: MonadThrow m => SpacesRequestBuilder -> UTCTime -> m SpacesRequest
newSpacesRequest SpacesRequestBuilder { .. } time = do
req <- H.parseRequest
$ mconcat [ show reqMethod
, " "
, "https://"
, maybe mempty ((<> ".") . T.unpack . coerce) bucket
, regionSlug
$ fromMaybe (spaces ^. field @"region") overrideRegion
, "."
, "digitaloceanspaces.com/"
, maybe mempty (T.unpack . coerce) object
]
payload <- bodyLBS reqBody
let payloadHash = hashHex payload
newHeaders = overrideReqHeaders req payloadHash time
request = req
{ H.requestHeaders = headers <> newHeaders
, H.queryString =
maybe mempty (H.renderQuery True) subresources
<> maybe mempty (H.renderQuery False) queryString
, H.requestBody = reqBody
}
canonicalRequest = mkCanonicalized (fromMaybe mempty subresources)
(fromMaybe mempty queryString)
request
payloadHash
return
$ SpacesRequest
{ method = reqMethod, headers = headers <> newHeaders, .. }
where
reqMethod = fromMaybe GET method
reqBody = fromMaybe (RequestBodyLBS LB.empty) body
-- | Canonicalize a 'Request'
mkCanonicalized :: Query -- ^ Subresources
-> Query -- ^ Query string
-> Request
-> Hashed -- ^ The hashed 'RequestBody'
-> Canonicalized Request
mkCanonicalized subresources query request payloadHash = Canonicalized
$ C.intercalate "\n"
[ request & H.method
, request & H.path
, renderSubresources subresources
<> H.renderQuery False query
, request
& H.requestHeaders
& canonicalizeHeaders
& unCanonicalized
, request & H.requestHeaders & joinHeaderNames
, uncompute payloadHash
]
-- | This is required to encode the subresources query string correctly in the
-- canonical request. Empty query keys require a trailing @=@, which are not
-- included with 'H.renderQuery'
renderSubresources :: Query -> ByteString
renderSubresources = C.intercalate "&" . fmap renderQueryItem . sort
where
renderQueryItem :: QueryItem -> ByteString
renderQueryItem (k, Nothing) = k <> "="
renderQueryItem (k, Just v) = k <> "=" <> v
-- | Generate a t'StringToSign'
mkStringToSign :: SpacesRequest -> StringToSign
mkStringToSign req@SpacesRequest { .. } = StringToSign
$ C.intercalate "\n"
[ "AWS4-HMAC-SHA256"
, fmtAmzTime time
, mkCredentials req & uncompute
, canonicalRequest
& coerce
& LB.fromStrict
& hashHex
& uncompute
]
-- | Generate a t'Signature'
mkSignature :: SpacesRequest -> StringToSign -> Signature
mkSignature SpacesRequest { .. } str = Signature
. B16.encode
. keyedHash (uncompute str)
. keyedHash "aws4_request"
. keyedHash "s3"
. keyedHash (spaces ^. field @"region" & regionSlug)
. keyedHash (fmtAmzDate time)
$ "AWS4" <> (spaces ^. field @"secretKey" & coerce)
-- | Create an t'Authorization' corresponding to the required AWS v4
-- @Authorization@ header
mkAuthorization :: SpacesRequest -> StringToSign -> Authorization
mkAuthorization req@SpacesRequest { .. } str = Authorization
$ C.concat [ "AWS4-HMAC-SHA256 Credential="
<> spaces ^. field @"accessKey" . position @1
<> "/"
<> uncompute cred
, ", SignedHeaders=" <> joinHeaderNames headers
, ", Signature=" <> uncompute sig
]
where
cred = mkCredentials req
sig = mkSignature req str
-- | Create t'Credentials' containing your 'AccessKey' and the request 'Region'
mkCredentials :: SpacesRequest -> Credentials
mkCredentials SpacesRequest { .. } = Credentials
$ C.intercalate "/"
[ fmtAmzDate time
, spaces ^. field @"region" & regionSlug
, "s3"
, "aws4_request"
]
-- | Required to override @http-client@ automatically setting the Content-Length header and
-- setting obligatory headers for AWS v4 API requests
overrideReqHeaders
:: Request
-> Hashed -- ^ The SHA256 hash of the request body; required in AWS v4
-> UTCTime
-> [Header]
overrideReqHeaders req hb time = (req & H.requestHeaders) <> newHeaders
where
newHeaders = [ (CI.mk "host", req & H.host)
, (CI.mk "x-amz-content-sha256", uncompute hb)
, (CI.mk "x-amz-date", fmtAmzTime time)
]
-- | Canonicalize @['Header']@s
canonicalizeHeaders :: [Header] -> Canonicalized [Header]
canonicalizeHeaders = Canonicalized
. C.unlines
. fmap (\(n, v) -> n <> ":" <> v)
. sort
. fmap (first (toLowerBS . CI.original))
joinHeaderNames :: [Header] -> ByteString
joinHeaderNames =
C.intercalate ";" . sort . fmap (toLowerBS . CI.original . fst)
fmtAmzDate :: UTCTime -> ByteString
fmtAmzDate = C.pack . formatTime defaultTimeLocale "%Y%m%d"
fmtAmzTime :: UTCTime -> ByteString
fmtAmzTime = C.pack . formatTime defaultTimeLocale "%Y%m%dT%H%M%SZ"
keyedHash :: ByteString -> ByteString -> ByteString
keyedHash bs k = convert $ hmac @_ @_ @SHA256 k bs
hashHex :: LB.ByteString -> Hashed
hashHex = Hashed . B16.encode . convert . hashlazy @SHA256