front-0.0.0.2: examples/todo/ServantTodo.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
module ServantTodo where
import Conduit
import Control.Concurrent (threadDelay)
import Control.Monad (unless, when)
import Control.Monad.Catch (catch)
import Crypto.Random (drgNew)
import Data.Data
import Data.Default (def)
import qualified Data.HashMap.Strict as HashMap
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8)
import Data.Time (defaultTimeLocale,
formatTime)
import Data.Time.Clock (UTCTime (..),
getCurrentTime)
import Network.Wai (Application)
import Network.Wai.Handler.Warp
import Network.Wai.Handler.WebSockets
import Network.WebSockets hiding (Headers)
import Prelude hiding (interact)
import Servant
import Servant.HTML.Blaze
import Servant.Server.Experimental.Auth (mkAuthHandler)
import Servant.Server.Experimental.Auth.Cookie
import System.Directory (createDirectoryIfMissing,
doesFileExist,
getModificationTime,
listDirectory,
removeFile)
import System.FilePath.Posix ((<.>), (</>))
import System.Random (randomRIO)
import Text.Blaze.Front.Html5 ((!))
import Text.Blaze.Front.Renderer (renderNewMarkup)
import Text.Blaze.Html5 (Html)
import Control.Concurrent.STM.Lifted as STM
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Char8 as BSC8
import qualified Data.ByteString.Lazy as BL
import qualified Data.List as L
import qualified Data.Text as T
import qualified Text.Blaze.Front.Html5 as H
import qualified Text.Blaze.Front.Html5.Attributes as A
import Bridge
import Todo
import Web.Front.Broadcast
type API = Header "cookie" T.Text :> Get '[HTML] (Cookied Html)
:<|> "static" :> Raw
-- * App
data Config = Config
{ model :: TVar Model
, channel :: TChan (Out (Action Msg))
, static :: FilePath
, clients :: TVar [Int]
-- cookie settings
, authCookieSettings :: AuthCookieSettings
, generateKey :: (IO ()) -- ^ An action to create a new key
, randomSource :: RandomSource
, serverKeySet :: FileKeySet
}
-- * API
api :: Proxy API
api = Proxy
server :: Config -> Server API
server cfg = serveRoot cfg :<|> serveStatic cfg
where
addSession' = addSession
(authCookieSettings cfg) -- the settings
(randomSource cfg) -- random source
(serverKeySet cfg) -- server key set
serveRoot cfg'@Config{..} _mclient = do
(clientId, state) <- liftIO $ do
clientId <- clientSession cfg' _mclient
setSession cfg' clientId
state <- readTVarIO model
pure (clientId, state)
addSession'
(def { ssExpirationType = MaxAge })
clientId
(renderNewMarkup $ do
H.html $ do
H.head $ do
H.title "TODO"
H.script ! A.src "/static/bundle.js" $ ""
H.body $ do
H.div ! A.id "root" $ renderModel state)
serveStatic Config{..} = serveDirectoryWebApp static
addClient :: IO Int
addClient = randomRIO (0, 1000000)
-- * Helpers
checkSession :: Config -> PendingConnection -> IO Int
checkSession cfg =
clientSession cfg . fmap decodeUtf8 . HashMap.lookup "cookie"
. HashMap.fromList . requestHeaders . pendingRequest
clientSession :: Config -> Maybe Text -> IO Int
clientSession cfg@Config{..} mclient = do
result <- lookupSession cfg mclient
case result of
Nothing -> addClient >>= (\x -> set cfg x >> pure x)
Just cid -> pure cid
where
set conf clientNum = setSession conf clientNum
lookupSession :: Config -> Maybe Text -> IO (Maybe Int)
lookupSession Config{..} = \client -> do
case client of
Nothing -> pure Nothing
Just c -> do
msession <- getHeaderSession authCookieSettings serverKeySet c `catch` ex
case epwSession <$> msession of
Nothing -> pure Nothing
Just cl -> pure $ pure cl
where
ex :: AuthCookieExceptionHandler IO
ex _e = pure Nothing
setSession :: MonadIO m => Config -> Int -> m ()
setSession Config{..} clientId = atomically $ do
ids <- readTVar clients
unless (clientId `elem` ids) $ modifyTVar' clients (clientId :)
-- * Main
main :: IO ()
main = do
let fksp = FileKSParams
{ fkspKeySize = 16
, fkspMaxKeys = 3
, fkspPath = "./test-key-set"
}
cfg <- Config
<$> STM.newTVarIO initModel
<*> atomically newBroadcastTChan
<*> pure "./static"
<*> newTVarIO []
-- cookie settings
<*> (pure $ def { acsCookieFlags = ["HttpOnly"] })
<*> pure (mkFileKey fksp)
<*> mkRandomSource drgNew 1000
<*> (mkFileKeySet fksp)
putStrLn "Server up and running on http://localhost:3000/"
run 3000 $ app cfg
-- | Custom handler that bluntly reports any occurred errors.
authHandler :: AuthCookieHandler (Maybe Int)
authHandler acs sks = mkAuthHandler $ \request ->
(getSession acs sks request) `catch` handleEx >>= maybe
(throwError err403 {errBody = "No cookies"})
(return)
where
handleEx :: AuthCookieExceptionHandler Handler
handleEx ex = throwError err403 {errBody = BL.fromStrict . BSC8.pack $ show ex}
app :: Config -> Application
app cfg@Config{..} = websocketsOr defaultConnectionOptions wsApp mainApp
where
wsApp :: ServerApp
wsApp pendingConn = do
let writeChan' = channel
_client <- checkSession cfg pendingConn
stream <- acceptRequest pendingConn
forkPingThread stream 60 -- Ping
readChan' <- atomically $ dupTChan writeChan'
interact stream writeChan' readChan' model (_client)
mainApp = serveWithContext
(Proxy :: Proxy API)
((authHandler authCookieSettings serverKeySet) :. EmptyContext)
(server cfg)
----------------------------------------------------------------------------
-- KeySet
-- A custom implementation of a keyset on top of 'RenewableKeySet'.
-- Keys are stored as files with base64 encoded data in 'test-key-set' directory.
-- To add a key just throw a file into the directory.
-- To remove a key delete corresponding file in the directory.
-- Both operations can be performed via web interface (see '/keys' page).
data FileKSParams = FileKSParams
{ fkspPath :: FilePath
, fkspMaxKeys :: Int
, fkspKeySize :: Int
}
data FileKSState = FileKSState
{ fkssLastModified :: UTCTime } deriving Eq
type FileKeySet = RenewableKeySet FileKSState FileKSParams
mkFileKey :: FileKSParams -> IO ()
mkFileKey FileKSParams{..} = (,) <$> mkName <*> mkKey >>= uncurry writeFile where
mkKey = generateRandomBytes fkspKeySize
>>= return
. BSC8.unpack
. Base64.encode
mkName = getCurrentTime
>>= return
. (fkspPath </>)
. (<.> "b64")
. formatTime defaultTimeLocale "%0Y%m%d%H%M%S"
>>= \name -> do
exists <- doesFileExist name
if exists
then (threadDelay 1000000) >> mkName
-- ^ we don't want to change the keys that often
else return name
mkFileKeySet :: (MonadIO m, MonadThrow m)
=> FileKSParams
-> m (RenewableKeySet FileKSState FileKSParams)
mkFileKeySet = mkKeySet where
mkKeySet FileKSParams {..} = do
liftIO $ do
createDirectoryIfMissing True fkspPath
listDirectory fkspPath >>= \fs -> when (null fs) $
mkFileKey FileKSParams {..}
let fkssLastModified = UTCTime (toEnum 0) 0
mkRenewableKeySet
RenewableKeySetHooks {..}
FileKSParams {..}
FileKSState {..}
rkshNeedUpdate FileKSParams {..} (_, FileKSState {..}) = do
lastModified <- liftIO $ getModificationTime fkspPath
return (lastModified > fkssLastModified)
getLastModifiedFiles FileKSParams {..} = listDirectory fkspPath
>>= return . map (fkspPath </>)
>>= \fs -> zip <$> (mapM getModificationTime fs) <*> (return fs)
>>= return
. map snd
. L.take fkspMaxKeys
. L.reverse
. L.sort
readKey = fmap (either (error "wrong key format") id . Base64.decode . BSC8.pack) . readFile
rkshNewState FileKSParams {..} (_, s) = liftIO $ do
lastModified <- getModificationTime fkspPath
keys <- getLastModifiedFiles FileKSParams {..} >>= mapM readKey
return (keys, s {fkssLastModified = lastModified})
rkshRemoveKey FileKSParams {..} key = liftIO $ getLastModifiedFiles FileKSParams {..}
>>= \fs -> zip fs <$> mapM readKey fs
>>= return . filter ((== key) . snd)
>>= mapM_ (removeFile . fst)