nuthatch/src/Nuthatch/Http/Server.hs

256 lines
9.1 KiB
Haskell
Raw Normal View History

module Nuthatch.Http.Server
( server
, utf8Boundary
) where
import Control.Exception (SomeException)
import Control.Monad (forM, when)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson as A
import Data.Either (isRight)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
import Database.PostgreSQL.Simple (Only (..))
import Network.HTTP.Types
( hContentType
, methodGet
, status200
, status400
, status404
, status405
, urlDecode
)
import Network.Wai (Request (..), responseFile, responseLBS)
import Nuthatch.Api
import Nuthatch.App.Env
import Nuthatch.Audit (audit)
import Nuthatch.Browse
import Nuthatch.Config
import qualified Nuthatch.Db as Db
import Nuthatch.Http.Auth
import Nuthatch.Http.Error
import Nuthatch.Mutation.Effects
import Nuthatch.Mutation.Service
import Nuthatch.Recovery (trySynchronous)
import Nuthatch.Transfer
import Nuthatch.Types
import Servant
import System.Directory (doesFileExist)
server :: Env -> Server Api
server env =
pure (HealthResponse True)
:<|> handleReady env
:<|> handleAppRequest
:<|> staticAssetsServer
:<|> handleLoginRequest env
:<|> handleOidcCallback env
:<|> handleLogoutRequest env
:<|>
( handleSessionRequest
:<|> handleBrowseRequest env
:<|> handleDownloadRequest env
:<|> handleUploadRequest env
:<|> handleDeleteRequest env
:<|> handleDeletePlanRequest env
:<|> handleDeleteBatchRequest env
)
utf8Boundary :: Application -> Application
utf8Boundary downstream request sendResponse
| requestUtf8Valid request = downstream request sendResponse
| otherwise =
sendResponse $
responseLBS status400
[(hContentType, "application/json")]
(A.encode InvalidUtf8)
requestUtf8Valid :: Request -> Bool
requestUtf8Valid request =
validUtf8 (urlDecode False (rawPathInfo request))
&& all validQueryItem (queryString request)
where
validQueryItem (name, value) =
validUtf8 name && maybe True validUtf8 value
validUtf8 = isRight . TE.decodeUtf8'
handleReady :: Env -> Handler ReadyResponse
handleReady env = do
pg <- liftIO $
trySynchronous (Db.query_ (envDb env) "SELECT 1")
:: Handler (Either SomeException [Only Int])
case pg of
Right _ -> pure (ReadyResponse True)
Left _ -> throwOperationError err503 ReadyDependencyUnavailable
handleAppRequest
:: SessionContext
-> Handler (Headers '[Header "Content-Security-Policy" Text] HtmlPage)
handleAppRequest _ = do
page <- liftIO (TIO.readFile "web/dist/index.html")
pure (addHeader applicationContentSecurityPolicy (HtmlPage page))
handleSessionRequest :: SessionContext -> Handler SessionResponse
handleSessionRequest session =
case sessionCsrf session of
Nothing -> throwOperationError err403 AuthenticationRequired
Just csrf ->
let user = sessionUser session
in pure SessionResponse
{ sessionResponseUserId = suUserId user
, sessionResponseDisplay = suDisplay user
, sessionResponseCsrf = csrf
}
applicationContentSecurityPolicy :: Text
applicationContentSecurityPolicy =
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; \
\base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"
staticAssetsServer :: SessionContext -> Server Raw
staticAssetsServer _ =
Tagged $ \request sendResponse ->
case (requestMethod request, pathInfo request) of
(method, [asset])
| method == methodGet
, validAssetName asset -> do
let path = "web/dist/assets/" <> T.unpack asset
exists <- doesFileExist path
if exists
then sendResponse $
responseFile
status200
[ (hContentType, assetContentType asset)
, ("X-Content-Type-Options", "nosniff")
, ("Cache-Control", "public, max-age=31536000, immutable")
]
path
Nothing
else sendResponse (responseLBS status404 [] "")
(method, _) | method /= methodGet ->
sendResponse (responseLBS status405 [] "")
_ ->
sendResponse (responseLBS status404 [] "")
where
validAssetName asset =
not (T.null asset)
&& T.all
(\character ->
character >= 'a' && character <= 'z'
|| character >= 'A' && character <= 'Z'
|| character >= '0' && character <= '9'
|| character `elem` (".-_" :: String))
asset
assetContentType asset
| ".js" `T.isSuffixOf` asset = "text/javascript; charset=utf-8"
| ".css" `T.isSuffixOf` asset = "text/css; charset=utf-8"
| otherwise = "application/octet-stream"
handleDeleteRequest
:: Env
-> SessionContext
-> DeleteRequest
-> Handler DeleteResponse
handleDeleteRequest env session request = do
requireCsrf "delete" DeleteCsrfRejected session (deleteCsrf request)
let user = sessionUser session
operation = deleteOperationId request
root = deleteRoot request
deleteAttempt <- liftIO $
tryRecoveryDependency
(runDelete env user root (deletePath request) operation)
outcome <- case deleteAttempt of
Left _ -> do
liftIO $ audit "delete" "failed"
(Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "database_unavailable")
] <> objectKeyAudit (cfgProduct (envConfig env)) (deletePath request))
throwOperationError err503
(DeleteMutationRejected "database unavailable")
Right value -> pure value
result <- case outcome of
Left reason -> do
liftIO $ audit "delete" "failed"
(Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "object_store_unavailable")
] <> objectKeyAudit (cfgProduct (envConfig env)) (deletePath request))
throwOperationError err409 (DeleteMutationRejected reason)
Right value -> pure value
liftIO $ audit "delete" (mutationAuditResult result)
(Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation))
] <> mutationReasonFields result
<> objectKeyAudit (cfgProduct (envConfig env)) (deletePath request))
pure DeleteResponse
{ deleteResponseOperationId = operation
, deleteResponseResult = result
, deleteResponseIdempotencyWindowSeconds =
cfgIdempotencyWindowSec (cfgProduct (envConfig env))
}
handleDeleteBatchRequest
:: Env
-> SessionContext
-> DeleteBatchRequest
-> Handler DeleteBatchResponse
handleDeleteBatchRequest env session request = do
requireCsrf
"delete_batch"
DeletePrefixCsrfRejected
session
(deleteBatchCsrf request)
let items = deleteBatchItems request
when (null items || length items > cfgDeletePageSize (cfgProduct (envConfig env))) $ do
liftIO $ audit "delete_batch" "rejected"
(Just (unUserId (suUserId (sessionUser session))))
[ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
, ("reason", A.String "invalid_batch_size")
]
throwOperationError err409
(DeletePrefixMutationRejected "delete batch size is invalid")
results <- forM items $ \item -> do
deleteAttempt <- liftIO $ tryRecoveryDependency $
runDelete
env
(sessionUser session)
(deleteBatchRoot request)
(deleteBatchPath item)
(deleteBatchOperationId item)
let outcome = case deleteAttempt of
Left _ -> Left "database unavailable"
Right value -> value
failureReason = case deleteAttempt of
Left _ -> "database_unavailable"
Right (Left _) -> "object_store_unavailable"
Right (Right _) -> ""
liftIO $ audit "delete" (either (const "failed") mutationAuditResult outcome)
(Just (unUserId (suUserId (sessionUser session))))
([ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
, ("operation_id", A.toJSON
(unOperationId (deleteBatchOperationId item)))
] <> either
(const [("reason", A.String failureReason)])
mutationReasonFields
outcome
<> objectKeyAudit (cfgProduct (envConfig env)) (deleteBatchPath item))
pure $ case outcome of
Left message -> DeleteItemResponse
{ deleteItemPath = deleteBatchPath item
, deleteItemOperationId = deleteBatchOperationId item
, deleteItemResult = Nothing
, deleteItemError = Just message
}
Right result -> DeleteItemResponse
{ deleteItemPath = deleteBatchPath item
, deleteItemOperationId = deleteBatchOperationId item
, deleteItemResult = Just result
, deleteItemError = Nothing
}
pure (DeleteBatchResponse results)