packages feed

swarm-0.6.0.0: src/swarm-tournament/Swarm/Web/Tournament.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- A web service for serving Swarm tournaments.
module Swarm.Web.Tournament (
  defaultPort,
  AppData (..),
  GitHubCredentials (..),
  DeploymentEnvironment (..),

  -- ** Development
  webMain,
  app,
) where

import Commonmark qualified as Mark (commonmark, renderHtml)
import Control.Lens hiding (Context)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except
import Control.Monad.Trans.Reader (runReaderT)
import Data.Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Either.Extra (maybeToEither)
import Data.Int (Int64)
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding (decodeUtf8, decodeUtf8', encodeUtf8)
import Data.Yaml (decodeEither', defaultEncodeOptions, encodeWith)
import Database.SQLite.Simple (withConnection)
import GHC.Generics (Generic)
import Network.HTTP.Client qualified as HC
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types (hCookie, ok200, renderSimpleQuery)
import Network.Wai (Request, requestHeaders, responseLBS)
import Network.Wai.Application.Static (defaultFileServerSettings)
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Parse (
  defaultParseRequestBodyOptions,
  setMaxRequestFileSize,
  setMaxRequestKeyLength,
  setMaxRequestNumFiles,
 )
import Servant
import Servant.Multipart
import Servant.Server.Experimental.Auth (AuthHandler, AuthServerData, mkAuthHandler)
import Swarm.Game.Scenario (ScenarioMetadata, scenarioMetadata)
import Swarm.Game.State (Sha1 (..))
import Swarm.Web.Auth
import Swarm.Web.Tournament.Database.Query
import Swarm.Web.Tournament.Type
import Swarm.Web.Tournament.Validate
import Swarm.Web.Tournament.Validate.FailureMode
import Swarm.Web.Tournament.Validate.Upload
import Web.Cookie

defaultPort :: Warp.Port
defaultPort = 5500

-- | NOTE: The default Servant server timeout is 30 sec;
-- see https://hackage.haskell.org/package/http-client-0.7.17/docs/Network-HTTP-Client-Internal.html#t:ResponseTimeout
defaultSolutionTimeout :: SolutionTimeout
defaultSolutionTimeout = SolutionTimeout 20

data DeploymentEnvironment
  = LocalDevelopment UserAlias
  | ProdDeployment

data AppData = AppData
  { swarmGameGitVersion :: Sha1
  , gitHubCredentials :: GitHubCredentials
  , persistence :: PersistenceLayer IO
  , developmentMode :: DeploymentEnvironment
  }

type LoginType = Headers '[Header "Location" TL.Text, Header "Set-Cookie" SetCookie] NoContent
type LoginHandler = Handler LoginType

type TournamentAPI =
  "api" :> "private" :> "upload" :> "scenario" :> Header "Referer" TL.Text :> AuthProtect "cookie-auth" :> MultipartForm Mem (MultipartData Mem) :> Verb 'POST 303 '[JSON] (Headers '[Header "Location" TL.Text] ScenarioCharacterization)
    :<|> "api" :> "private" :> "upload" :> "solution" :> Header "Referer" TL.Text :> AuthProtect "cookie-auth" :> MultipartForm Mem (MultipartData Mem) :> Verb 'POST 303 '[JSON] (Headers '[Header "Location" TL.Text] SolutionFileCharacterization)
    :<|> "scenario" :> Capture "sha1" Sha1 :> "metadata" :> Get '[JSON] ScenarioMetadata
    :<|> "scenario" :> Capture "sha1" Sha1 :> "fetch" :> Get '[PlainText] TL.Text
    :<|> "solution" :> Capture "sha1" Sha1 :> "fetch" :> Get '[PlainText] TL.Text
    :<|> "list" :> "games" :> Get '[JSON] [TournamentGame]
    :<|> "list" :> "game" :> Capture "sha1" Sha1 :> Get '[JSON] GameWithSolutions
    :<|> "api" :> "private" :> "login" :> "status" :> AuthProtect "cookie-auth" :> Get '[JSON] UserAlias
    :<|> "github-auth-callback" :> QueryParam "code" TokenExchangeCode :> Verb 'GET 303 '[JSON] LoginType
    :<|> "api" :> "private" :> "login" :> "local" :> Header "Referer" TL.Text :> Verb 'GET 303 '[JSON] LoginType
    :<|> "api" :> "private" :> "login" :> "logout" :> Header "Referer" TL.Text :> Verb 'GET 303 '[JSON] LoginType

mkApp :: AppData -> Servant.Server TournamentAPI
mkApp appData =
  uploadScenario appData
    :<|> uploadSolution appData
    :<|> getScenarioMetadata appData
    :<|> downloadRedactedScenario appData
    :<|> downloadSolution appData
    :<|> listScenarios
    :<|> listSolutions
    :<|> echoUsername
    :<|> doGithubCallback (authenticationStorage $ persistence appData) (gitHubCredentials appData)
    :<|> doLocalDevelopmentLogin (authenticationStorage $ persistence appData) (developmentMode appData)
    :<|> doLogout
 where
  echoUsername = return

type ToplevelAPI =
  TournamentAPI
    :<|> "api" :> Raw
    :<|> Raw

tournamentsApiHtml :: LBS.ByteString
tournamentsApiHtml =
  encodeUtf8
    . either (error . show) (Mark.renderHtml @())
    . Mark.commonmark ""
    $ T.pack "No documentation at this time."

toServantError :: Describable a => a -> ServerError
toServantError x = err500 {errBody = encodeUtf8 $ TL.fromStrict $ describeText x}

-- | We need to specify the data returned after authentication
type instance AuthServerData (AuthProtect "cookie-auth") = UserAlias

myAppCookieName :: BS.ByteString
myAppCookieName = "servant-auth-cookie"

data LoginProblem = LoginProblem
  { problemMessage :: TL.Text
  , loginLink :: TL.Text
  }
  deriving (Generic, ToJSON)

--- | The auth handler wraps a function from Request -> Handler UserAlias.
--- We look for a token in the request headers that we expect to be in the cookie.
--- The token is then passed to our `lookupAccount` function.
authHandler ::
  AuthenticationStorage IO ->
  GitHubCredentials ->
  DeploymentEnvironment ->
  AuthHandler Request UserAlias
authHandler authStorage creds deployMode = mkAuthHandler handler
 where
  url = case deployMode of
    LocalDevelopment _ -> "api/private/login/local"
    ProdDeployment -> decodeUtf8 . LBS.fromStrict $ genLoginUrl creds

  throw401 msg = throwError $ err401 {errBody = encode $ LoginProblem msg url}
  handler req = either throw401 lookupAccount $ do
    cookie <- maybeToEither "Missing cookie header" $ lookup hCookie $ requestHeaders req
    maybeToEither "Missing token in cookie"
      . fmap (decodeUtf8 . LBS.fromStrict)
      . lookup myAppCookieName
      $ parseCookies cookie

  -- A method that, when given a cookie/password, will return a 'UserAlias'.
  lookupAccount :: TL.Text -> Handler UserAlias
  lookupAccount cookieText = do
    maybeUser <- liftIO $ usernameFromCookie authStorage cookieText
    case maybeUser of
      Nothing -> throwError (err403 {errBody = encode $ LoginProblem "Invalid cookie password" url})
      Just usr -> return usr

-- * Handlers

defaultRedirectPage :: TL.Text
defaultRedirectPage = "/list-games.html"

defaultSolutionSubmissionRedirectPage :: TL.Text
defaultSolutionSubmissionRedirectPage = "/list-solutions.html"

uploadScenario ::
  AppData ->
  Maybe TL.Text ->
  UserAlias ->
  MultipartData Mem ->
  Handler (Headers '[Header "Location" TL.Text] ScenarioCharacterization)
uploadScenario (AppData gameVersion _ persistenceLayer _) maybeRefererUrl userName multipartData =
  Handler . fmap addH . withExceptT toServantError . ExceptT $
    validateScenarioUpload
      args
      gameVersion
 where
  addH = addHeader (fromMaybe defaultRedirectPage maybeRefererUrl)
  args =
    CommonValidationArgs
      defaultSolutionTimeout
      $ PersistenceArgs
        userName
        multipartData
        (scenarioStorage persistenceLayer)

uploadSolution ::
  AppData ->
  Maybe TL.Text ->
  UserAlias ->
  MultipartData Mem ->
  Handler (Headers '[Header "Location" TL.Text] SolutionFileCharacterization)
uploadSolution (AppData _ _ persistenceLayer _) maybeRefererUrl userName multipartData =
  Handler . fmap addH . withExceptT toServantError . ExceptT $
    validateSubmittedSolution
      args
      ((getContent . scenarioStorage) persistenceLayer)
 where
  addH = addHeader (fromMaybe defaultSolutionSubmissionRedirectPage maybeRefererUrl)
  args =
    CommonValidationArgs
      defaultSolutionTimeout
      $ PersistenceArgs
        userName
        multipartData
        (solutionStorage persistenceLayer)

getScenarioMetadata :: AppData -> Sha1 -> Handler ScenarioMetadata
getScenarioMetadata (AppData _ _ persistenceLayer _) scenarioSha1 =
  Handler . withExceptT toServantError $ do
    doc <-
      ExceptT $
        maybeToEither (DatabaseRetrievalFailure scenarioSha1)
          <$> (getContent . scenarioStorage) persistenceLayer scenarioSha1

    s <- withExceptT RetrievedInstantiationFailure $ initScenarioObjectWithEnv doc
    return $ view scenarioMetadata s

genLoginUrl :: GitHubCredentials -> BS.ByteString
genLoginUrl creds =
  "https://github.com/login/oauth/authorize"
    <> renderSimpleQuery
      True
      [ ("client_id", clientId creds)
      ]

downloadSolution :: AppData -> Sha1 -> Handler TL.Text
downloadSolution (AppData _ _ persistenceLayer _) solutionSha1 = do
  Handler . withExceptT toServantError $ do
    ExceptT $
      maybeToEither (DatabaseRetrievalFailure solutionSha1)
        <$> (fmap $ fmap decodeUtf8) ((getContent . solutionStorage) persistenceLayer solutionSha1)

downloadRedactedScenario :: AppData -> Sha1 -> Handler TL.Text
downloadRedactedScenario (AppData _ _ persistenceLayer _) scenarioSha1 = do
  Handler . withExceptT toServantError $ do
    doc <-
      ExceptT $
        maybeToEither (DatabaseRetrievalFailure scenarioSha1)
          <$> (getContent . scenarioStorage) persistenceLayer scenarioSha1

    rawYamlDict :: Map Key Value <- withExceptT YamlParseFailure . except . decodeEither' $ LBS.toStrict doc
    let redactedDict = M.delete "solution" rawYamlDict
    withExceptT DecodingFailure . except . decodeUtf8' . LBS.fromStrict $
      encodeWith defaultEncodeOptions redactedDict

listScenarios :: Handler [TournamentGame]
listScenarios =
  Handler . liftIO . withConnection databaseFilename $ runReaderT listGames

listSolutions :: Sha1 -> Handler GameWithSolutions
listSolutions sha1 =
  Handler . liftIO . withConnection databaseFilename . runReaderT $ listSubmissions sha1

doGithubCallback ::
  AuthenticationStorage IO ->
  GitHubCredentials ->
  Maybe TokenExchangeCode ->
  LoginHandler
doGithubCallback authStorage creds maybeCode = do
  c <- maybe (fail "Missing 'code' parameter") return maybeCode

  manager <- liftIO $ HC.newManager tlsManagerSettings
  receivedTokens <- exchangeCode manager creds c

  let aToken = token $ accessToken receivedTokens
  userInfo <- fetchAuthenticatedUser manager aToken
  let user = UserAlias $ login userInfo
  x <- doLoginResponse authStorage defaultRedirectPage user
  liftIO . withConnection databaseFilename . runReaderT $ do
    insertGitHubTokens user receivedTokens
  return x

doLocalDevelopmentLogin ::
  AuthenticationStorage IO ->
  DeploymentEnvironment ->
  Maybe TL.Text ->
  LoginHandler
doLocalDevelopmentLogin authStorage envType maybeRefererUrl =
  case envType of
    ProdDeployment -> error "Login bypass not available in production"
    LocalDevelopment user ->
      doLoginResponse authStorage refererUrl user
 where
  refererUrl = fromMaybe defaultRedirectPage maybeRefererUrl

makeCookieHeader :: BS.ByteString -> SetCookie
makeCookieHeader val =
  defaultSetCookie
    { setCookieName = myAppCookieName
    , setCookieValue = val
    , setCookiePath = Just "/api/private"
    }

doLogout :: Maybe TL.Text -> LoginHandler
doLogout maybeRefererUrl =
  return $
    addHeader (fromMaybe defaultRedirectPage maybeRefererUrl) $
      addHeader ((makeCookieHeader "") {setCookieMaxAge = Just 0}) NoContent

doLoginResponse ::
  AuthenticationStorage IO ->
  TL.Text ->
  UserAlias ->
  LoginHandler
doLoginResponse authStorage refererUrl userAlias = do
  cookieString <-
    liftIO $ cookieFromUsername authStorage userAlias
  return $
    addHeader refererUrl $
      addHeader (makeCookieHeader $ LBS.toStrict $ encodeUtf8 cookieString) NoContent

-- * Web app declaration

app :: Bool -> AppData -> Application
app unitTestFileserver appData =
  Servant.serveWithContext (Proxy :: Proxy ToplevelAPI) context $
    server unitTestFileserver
 where
  size100kB = 100_000 :: Int64

  multipartOpts :: MultipartOptions Mem
  multipartOpts =
    (defaultMultipartOptions (Proxy :: Proxy Mem))
      { generalOptions =
          setMaxRequestFileSize size100kB
            . setMaxRequestKeyLength 64
            . setMaxRequestNumFiles 1
            $ defaultParseRequestBodyOptions
      }

  thisAuthHandler =
    authHandler
      (authenticationStorage $ persistence appData)
      (gitHubCredentials appData)
      (developmentMode appData)
  context = thisAuthHandler :. multipartOpts :. EmptyContext

  server :: Bool -> Server ToplevelAPI
  server fakeFileserverForUnitTest =
    mkApp appData
      :<|> Tagged serveDocs
      :<|> fileserver
   where
    fileserver =
      if fakeFileserverForUnitTest
        then -- This is required because the data only files available to
        -- the testing environment are included in the cabal file
        -- in the "data-files" clause.
        -- However, since that clause is global to the package,
        -- we choose not to include the tournament server's web
        -- files there.
        -- Instead, we manually stub the paths that are used as redirects
        -- so that the web API invocation does not 404 when looking for them.

          serveDirectoryEmbedded
            [ (TL.unpack defaultRedirectPage, "Hello World!")
            , (TL.unpack defaultSolutionSubmissionRedirectPage, "Hello World!")
            ]
        else
          serveDirectoryWith
            (defaultFileServerSettings "tournament/web")

    serveDocs _ resp =
      resp $ responseLBS ok200 [htmlType] tournamentsApiHtml
    htmlType = ("Content-Type", "text/html")

webMain ::
  AppData ->
  Warp.Port ->
  IO ()
webMain appData port = Warp.runSettings settings $ app False appData
 where
  settings = Warp.setPort port Warp.defaultSettings