packages feed

spire-http-0.1.0.0: src/Spire/Http/Cors.hs

-- | CORS (Cross-Origin Resource Sharing) middleware.
--
-- Handles preflight OPTIONS requests automatically and adds
-- Access-Control-* headers to all responses.
module Spire.Http.Cors
  ( -- * Config
    CorsConfig (..)
  , defaultCors
  , permissiveCors
    -- * Layer
  , corsLayer
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Network.HTTP.Types (status200, status204, Header, ResponseHeaders)

import Spire (Middleware, middleware)
import Spire.Service (Service (..))
import Http.Core (Request (..), Response (..))


-- | CORS configuration.
data CorsConfig = CorsConfig
  { corsAllowOrigins :: ![ByteString]
    -- ^ Allowed origins. @["*"]@ allows all.
  , corsAllowMethods :: ![ByteString]
    -- ^ Allowed methods for preflight.
  , corsAllowHeaders :: ![ByteString]
    -- ^ Allowed request headers for preflight.
  , corsExposeHeaders :: ![ByteString]
    -- ^ Response headers the browser can access.
  , corsMaxAge :: !(Maybe Int)
    -- ^ Preflight cache duration in seconds.
  , corsAllowCredentials :: !Bool
    -- ^ Allow cookies/auth headers.
  }

-- | Restrictive defaults: no origins allowed.
defaultCors :: CorsConfig
defaultCors = CorsConfig
  { corsAllowOrigins     = []
  , corsAllowMethods     = ["GET", "POST", "PUT", "DELETE", "PATCH"]
  , corsAllowHeaders     = ["Content-Type", "Authorization", "Accept"]
  , corsExposeHeaders    = []
  , corsMaxAge           = Just 86400
  , corsAllowCredentials = False
  }

-- | Allow everything. Good for development.
permissiveCors :: CorsConfig
permissiveCors = defaultCors
  { corsAllowOrigins     = ["*"]
  , corsAllowHeaders     = ["*"]
  , corsAllowCredentials = False
  }


-- | CORS middleware.
corsLayer :: CorsConfig -> Middleware IO (Request ByteString) (Response ByteString)
corsLayer cfg = middleware $ \(Service inner) -> Service $ \req ->
  if requestMethod req == "OPTIONS"
    then pure (preflightResponse cfg req)
    else do
      resp <- inner req
      pure (addCorsHeaders cfg req resp)


-- | Build preflight response.
preflightResponse :: CorsConfig -> Request ByteString -> Response ByteString
preflightResponse cfg req = Response status204 hdrs ""
  where
    hdrs = corsHeaders cfg req ++
      maybe [] (\age -> [("Access-Control-Max-Age", BS8.pack (show age))]) (corsMaxAge cfg)


-- | Add CORS headers to a normal response.
addCorsHeaders :: CorsConfig -> Request ByteString -> Response ByteString -> Response ByteString
addCorsHeaders cfg req resp =
  resp { responseHeaders = responseHeaders resp ++ corsHeaders cfg req }


-- | Common CORS headers.
corsHeaders :: CorsConfig -> Request ByteString -> ResponseHeaders
corsHeaders cfg req =
  [ ("Access-Control-Allow-Origin", origin)
  , ("Access-Control-Allow-Methods", BS8.intercalate ", " (corsAllowMethods cfg))
  , ("Access-Control-Allow-Headers", BS8.intercalate ", " (corsAllowHeaders cfg))
  ] ++
  [ ("Access-Control-Expose-Headers", BS8.intercalate ", " (corsExposeHeaders cfg))
  | not (null (corsExposeHeaders cfg)) ] ++
  [ ("Access-Control-Allow-Credentials", "true")
  | corsAllowCredentials cfg ]
  where
    origin
      | corsAllowOrigins cfg == ["*"] = "*"
      | otherwise = case lookup "origin" (requestHeaders req) of
          Just o | o `elem` corsAllowOrigins cfg -> o
          _ -> ""