packages feed

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)