om-http-0.6.0.0: src/OM/HTTP.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
{- | Miscellaneous HTTP Utilities. -}
module OM.HTTP (
runTlsRedirect,
hstsDirective,
requestLogging,
setServer,
insertResponseHeaderIfMissing,
overwriteResponseHeader,
staticSite,
logExceptionsAndContinue,
sshConnect,
staticPage,
defaultIndex,
BearerToken(..),
emptyApp,
) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Exception (someExceptionContext, try)
import Control.Exception.Context (displayExceptionContext)
import Control.Exception.Safe (SomeException, bracket, finally, throwM, tryAny)
import Control.Monad (join, void)
import Control.Monad.IO.Class (MonadIO(liftIO))
import Control.Monad.Logger.Aeson
( LoggingT(runLoggingT), Message((:#)), (.=), Loc, LogLevel, LogSource, LogStr
, MonadLoggerIO, logError, logInfo
)
import Data.Aeson (object)
import Data.Base64.Types (extractBase64)
import Data.ByteString (ByteString)
import Data.ByteString.Base64 (encodeBase64)
import Data.List ((\\))
import Data.Maybe (catMaybes)
import Data.Set (Set, member)
import Data.String (IsString(fromString))
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8')
import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
import Data.UUID (UUID)
import Data.UUID.V1 (nextUUID)
import Data.Version (Version, showVersion)
import Language.Haskell.TH (Code(examineCode), Q, TExp, runIO)
import Language.Haskell.TH.Syntax (addDependentFile)
import Network.HTTP.Types
( Status(statusCode, statusMessage), Header, HeaderName
, internalServerError500, methodNotAllowed405, movedPermanently301, ok200
, status404
)
import Network.Mime (defaultMimeLookup)
import Network.Socket
( AddrInfo(addrAddress), Family(AF_INET), SocketType(Stream), Socket, close
, connect, defaultProtocol, getAddrInfo, socket
)
import Network.Socket.ByteString (recv, sendAll)
import Network.Wai
( Request
( isSecure, pathInfo, rawPathInfo, rawQueryString, remoteHost
, requestHeaders, requestMethod
)
, Application, Middleware, Response, ResponseReceived, mapResponseHeaders
, responseHeaders, responseLBS, responseRaw, responseStatus
)
import Network.Wai.Handler.Warp
( HostPreference, Settings, defaultSettings, runSettings, setHost, setPort
)
import OM.Show (showt)
import Prelude
( Applicative(pure), Either(Left, Right), Eq((/=), (==))
, Foldable(elem, foldr), Functor(fmap), Maybe(Just, Nothing)
, Monad((>>), (>>=), return), MonadFail(fail), Monoid(mempty)
, RealFrac(truncate), Semigroup((<>)), Show(show), Traversable(mapM), ($)
, (++), (.), (<$>), (=<<), FilePath, IO, Int, String, concat, drop, filter
, fst, id, mapM_, otherwise, putStrLn, seq, zip
)
import Servant.API (ToHttpApiData(toUrlPiece))
import System.Directory (getDirectoryContents)
import System.FilePath.Posix ((</>), combine)
import System.Posix.Files (getFileStatus, isDirectory, isRegularFile)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BSL
import qualified Data.ByteString.Lazy.Char8 as BSL8
import qualified Data.CaseInsensitive as CI
import qualified Data.Text as T
{- |
Runs a web server on port 80, that redirects to the given url. Does
request logging, and sets the HSTS Directive header, and in the unlikely
event of excptions it will also catch and log them.
-}
runTlsRedirect
:: (Loc -> LogSource -> LogLevel -> LogStr -> IO ()) {- ^ Logging backend. -}
-> HostPreference
-> ByteString {- ^ Server name. -}
-> Version {- ^ Server version. -}
-> ByteString {- ^ Target URL. -}
-> IO ()
runTlsRedirect logging host serverName serverVersion url =
runSettings settings
. requestLogging logging mempty
. setServer serverName serverVersion
. hstsDirective 600
. logExceptionsAndContinue logging
$ tlsRedirect url
where
settings :: Settings
settings =
setPort 80
. setHost host
$ defaultSettings
{- |
Inject the HSTS directives, see
https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security.
-}
hstsDirective :: NominalDiffTime -> Middleware
hstsDirective age = insertResponseHeaderIfMissing header
where
header :: Header
header =
("Strict-Transport-Security", "max-age=" <> showt (truncate age :: Int))
{- | Insert a response header only if it has not already been inserted. -}
insertResponseHeaderIfMissing :: Header -> Middleware
insertResponseHeaderIfMissing (name, val) app req respond =
app req (respond . mapResponseHeaders doInsert)
where
doInsert :: [Header] -> [Header]
doInsert headers
| name `elem` (fst <$> headers) = headers
| otherwise = (name, val):headers
{- |
TLS redirect. An 'Application' that redirects unsecured requests to
the secure HTTPS site.
-}
tlsRedirect :: ByteString -> Application
tlsRedirect url _req respond = respond $
responseLBS
movedPermanently301
[
("Location", url),
("Content-Type", "text/html")
]
(
"<html>\
\<head>\
\</head>\
\<body>\
\Please use our secure site,\
\<a href=\"" <> BSL.fromStrict url <> "\">here</a>\
\</body>\
\</html>"
)
{- | Set the @Server:@ header. -}
setServer :: ByteString -> Version -> Middleware
setServer serviceName version =
overwriteResponseHeader ("Server", serverValue)
where
{- | The value of the @Server:@ header. -}
serverValue = serviceName <> "/" <> fromString (showVersion version)
{- |
Inserts a response header, clobbering any and all existing values for
the given header.
-}
overwriteResponseHeader :: Header -> Middleware
overwriteResponseHeader (name, value) app req respond =
app req (respond . mapResponseHeaders go)
where
go :: [Header] -> [Header]
go headers =
(name, value) : filter ((/= name) . fst) headers
{- |
Logs an HTTP request by emitting two log messages. The first messages
logs that the request has begun. The second messages logs the status
result and timing of the request once it is finished.
> {"text":"Starting request: GET /api/me","meta":{..<request details>..}}
> {"text":"Request complete: GET /api/me","meta":{"status":"200 OK","time":"0.000370671s"}}
This can help debugging requests that hang or crash for whatever reason.
-}
requestLogging
:: (Loc -> LogSource -> LogLevel -> LogStr -> IO ())
-> Set HeaderName {-^ Headers to redact -}
-> Middleware
requestLogging logging redactHeaders app req respond =
(`runLoggingT` logging) $ do
logInfo $ "Starting request: " <> reqStr :#
[ "method" .= decodeUtf8Safe (requestMethod req)
, "path" .= decodeUtf8Safe (rawPathInfo req)
, "query" .= decodeUtf8Safe (rawQueryString req)
, "secure" .= isSecure req
, "remote_host" .= show (remoteHost req)
, "headers" .=
fmap
(\(name, val) ->
decodeUtf8Safe (CI.original name)
<> ": "
<> if name `member` redactHeaders
then "<redacted>"
else decodeUtf8Safe val
)
(requestHeaders req)
]
now <- liftIO getCurrentTime
liftIO (try (app req (loggingRespond now))) >>= \case
Left err -> do
logError $
"`app` failed with exception. The exception will be re-thrown." :#
[ "exception" .= show (err :: SomeException)
, "context" .= displayExceptionContext (someExceptionContext err)
]
throwM err
Right val -> pure val
where
{- | Delegate to the underlying responder, and do some logging. -}
loggingRespond :: UTCTime -> Response -> IO ResponseReceived
loggingRespond start response =
(`runLoggingT` logging) $ do
{-
Execute the underlying responder first so we get an accurate
measurement of the request duration.
-}
ack <- liftIO (try (respond response)) >>= \case
Left err -> do
logError $
"`respond` failed with exception. The exception will be re-thrown." :#
[ "exception" .= show (err :: SomeException)
, "context" .= displayExceptionContext (someExceptionContext err)
, "response" .= object
[ "status" .= showStatus (responseStatus response)
, "headers" .=
fmap
(\(name, val) ->
decodeUtf8Safe (CI.original name)
<> ": "
<> decodeUtf8Safe val
)
(responseHeaders response)
]
]
throwM err
Right ack -> pure ack
now <- ack `seq` liftIO getCurrentTime
logInfo $ "Request complete: " <> reqStr :#
[ "status" .= showStatus (responseStatus response)
, "time" .= show (diffUTCTime now start)
]
return ack
{- | A Text representation of the request, suitable for logging. -}
reqStr :: Text
reqStr =
decodeUtf8Safe
(requestMethod req <> " " <> rawPathInfo req <> rawQueryString req)
{- |
@instance Show Status@ shows the Haskell structure, which is
not suitable for logging.
-}
showStatus :: Status -> Text
showStatus stat =
(showt . statusCode) stat <> " " <> (decodeUtf8Safe . statusMessage) stat
{- |
Logs all exceptions, and returns a 500 Internal Server error.
This is useful because your wai framework won't always do what you
expect when it encounters random exceptions. For instance, an exception
thrown in IO may cause functionality of higher-level middlewares to be
bypassed unless they know how to catch and re-throw exceptions (making
them more complicated). This middleware explicitly will not re-throw
exceptions, unless those exceptions were encountered after the headers
have already been sent, e.g. when using 'Network.Wai.StreamingBody'.
What it will do is generate a unique id for the exception and print
that ID, so you can easily find it in the logs.
-}
logExceptionsAndContinue
:: (Loc -> LogSource -> LogLevel -> LogStr -> IO ()) {- ^ Logging backend. -}
-> Middleware
logExceptionsAndContinue logging app req respond = (`runLoggingT` logging) $
tryAny (liftIO (app req loggingRespond)) >>= \case
Right ack -> return ack
Left err -> do
uuid <- logProblem err
liftIO $ respond (errResponse uuid)
where
errResponse :: UUID -> Response
errResponse uuid =
responseLBS
internalServerError500
[("Content-Type", "text/plain")]
("Internal Server Error. Error ID: " <> showt uuid)
getUUID :: (MonadIO m) => m UUID
getUUID = liftIO nextUUID >>= \case
Nothing -> liftIO (threadDelay 1000) >> getUUID
Just uuid -> return uuid
loggingRespond :: Response -> IO ResponseReceived
loggingRespond response = (`runLoggingT` logging) $
tryAny (liftIO (respond response)) >>= \case
Right ack -> return ack
Left err -> do
void $ logProblem err
throwM err
logProblem :: (MonadLoggerIO m) => SomeException -> m UUID
logProblem err = do
uuid <- getUUID
logError $ "Internal Server Error" :#
[ "exception" .= show err
, "context" .= displayExceptionContext (someExceptionContext err)
, "id" .= uuid
]
return uuid
{- |
'Middleware' that provides an HTTP @CONNECT@ passthrough to the local
ssh port. Useful primarily for bypassing content-inspection firewalls.
-}
sshConnect :: Middleware
sshConnect app req respond =
case requestMethod req of
"CONNECT" ->
respond (responseRaw connProxy (responseLBS methodNotAllowed405 [] ""))
_ -> app req respond
where
{- |
Open a connection to the local ssh port and mediate the traffic between
that service and the client.
-}
connProxy :: IO ByteString -> (ByteString -> IO ()) -> IO ()
connProxy read_ write =
bracket
(socket AF_INET Stream defaultProtocol)
(\so -> close so `finally` write "")
(\so -> do
connect so =<<
(
getAddrInfo Nothing (Just "127.0.0.1") (Just "22") >>= \case
[] -> fail "Address not found: 127.0.0.1:22"
sa:_ -> return (addrAddress sa)
)
concurrently_
(pipeInbound so read_)
(pipeOutbound so write)
)
{- | Forward data coming from the client, going to the ssh service. -}
pipeInbound :: Socket -> IO ByteString -> IO ()
pipeInbound so read_ = do
bytes <- read_
if BS.null bytes
then return ()
else do
sendAll so bytes
pipeInbound so read_
{- | Forward data coming from the ssh service, going to the client. -}
pipeOutbound :: Socket -> (ByteString -> IO ()) -> IO ()
pipeOutbound so write = do
bytes <- recv so 4096
write bytes
if BS.null bytes
then return ()
else pipeOutbound so write
{- | Serve a static page at the given 'pathInfo'. -}
staticPage
:: [Text] {- ^ The path info. -}
-> ByteString {- ^ The content type. -}
-> BSL.ByteString {- ^ The response body content. -}
-> Middleware
staticPage path ct bytes app req respond =
if pathInfo req == path
then respond (responseLBS ok200 [("Content-Type", ct)] bytes)
else app req respond
{- | Rewrite: "\/" -> "/index.html". -}
defaultIndex :: Middleware
defaultIndex app request respond =
case pathInfo request of
[] -> app request {pathInfo = ["index.html"]} respond
_ -> app request respond
{- |
A bearer token, which is an instance of the necessary type classes to
be useful as a servant header value.
-}
newtype BearerToken = BearerToken {
unBearerToken :: Text
}
instance ToHttpApiData BearerToken where
toUrlPiece t = "Bearer " <> unBearerToken t
{- |
The Template-Haskell splice @$$(staticSite dir)@ will build a
'Middleware' that serves a set of static files determined at
compile time, or else passes the request to the underlying
'Network.Wai.Application'.
All files under @dir@ will be served relative to the root path of
your web server, so the file @\<dir\>\/foo\/bar.html@ will be served at
@http://your-web-site.com/foo/bar.html@
The content-type of the files being served will be guessed using
'defaultMimeLookup'.
-}
staticSite :: FilePath -> Q (TExp Middleware)
staticSite baseDir = join . runIO $ do
files <- readStaticFiles
mapM_ (printResource . fst) files
return $ mapM_ (addDependentFile . ((baseDir ++ "/") ++) . fst) files >> examineCode [||
let
{- |
Build a middleware that serves a single static file path, or
delegates to the underlying application.
-}
static :: (FilePath, String) -> Middleware
static (filename, content) app req respond =
let
{- | Guess the content type of the static file. -}
ct :: ByteString
ct =
defaultMimeLookup
. fromString
$ filename
in
if pathInfo req == T.split (== '/') (T.pack filename)
then
respond (
responseLBS
ok200
[("content-type", ct)]
(BSL8.pack content)
)
else app req respond
in
foldr ((.) . static) id files :: Middleware
||]
where
printResource :: String -> IO ()
printResource file =
putStrLn ("Generating static resource for: " ++ show file)
{- | Reads the static files that make up the admin user interface. -}
readStaticFiles :: IO [(FilePath, String)]
readStaticFiles =
let
findAll :: FilePath -> IO [FilePath]
findAll dir = do
contents <-
(\\ [".", ".."]) <$> getDirectoryContents (baseDir </> dir)
dirs <- catMaybes <$> mapM justDir contents
files <- catMaybes <$> mapM justFile contents
more <- concat <$> mapM (findAll . combine dir) dirs
return $ (combine dir <$> files) ++ more
where
justFile :: FilePath -> IO (Maybe FilePath)
justFile filename = do
isfile <-
isRegularFile <$>
getFileStatus (baseDir </> dir </> filename)
return $ if isfile then Just filename else Nothing
justDir :: FilePath -> IO (Maybe FilePath)
justDir filename = do
isdir <-
isDirectory <$>
getFileStatus (baseDir </> dir </> filename)
return $ if isdir then Just filename else Nothing
in do
allFiles <- findAll "."
allContent
<- mapM (fmap BS8.unpack . BS.readFile . combine baseDir) allFiles
return (zip (drop 2 <$> allFiles) allContent)
{-| A WAI 'Application' that returns 404 not found for everything. -}
emptyApp :: Application
emptyApp _req respond =
respond
(
responseLBS
status404
mempty
"not found"
)
{- | Safely decode UTF-8 bytes to Text, replacing invalid sequences with placeholder. -}
decodeUtf8Safe :: ByteString -> Text
decodeUtf8Safe bytes =
case decodeUtf8' bytes of
Left _err -> "<invalid utf8:" <> extractBase64 (encodeBase64 bytes) <> ">"
Right val -> val