packages feed

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