module Happstack.Server.Dialogs ( Dlg, Page, perform, showPage, DialogManager, makeDialogManager, closeDialogManager, dialog ) where import Happstack.Server import Control.Monad.Trans import Control.Monad.Reader import Control.Concurrent import Control.Concurrent.MVar import Data.Maybe import Data.Char import Data.Time import System.Random import Data.Map (Map) import qualified Data.Map as M {-| A value of a 'Dlg' type represents a dialog between the user and the application, after which the application builds a value of type 'a'. The trivial case is that the value is already known. Alternatively, it may be that there is some action to be performed, or else that the user needs to be asked or told something. -} data Dlg m a = Done a | Action (ServerPartT m (Dlg m a)) | Step (Page m) (Dlg m a) {-| A value of 'Page' type represents a way of rendering a page, given a unique ID that should be included in responses in order to reassociate the response with the current dialog. -} type Page m = Int -> ServerPartT m Response {- Dlg is a monad in the obvious way: return represents a dialog that has no steps; and (>>=) combines dialogs by doing the first part of the first dialog, and then continuing with the rest. -} instance Monad m => Monad (Dlg m) where return = Done Done x >>= y = y x Action x >>= y = Action (x >>= return . (>>= y)) Step p f >>= y = Step p (f >>= y) instance MonadTrans Dlg where lift = Action . lift . (>>= return . Done) instance MonadIO m => MonadIO (Dlg m) where liftIO = lift . liftIO {- Converts a 'ServerPartT' into a Dlg. This is essentially a mechanism for escaping the confines of the dialog mechanism and performing your own processing with the request. -} perform :: Monad m => ServerPartT m a -> Dlg m a perform x = Action (x >>= return . Done) {-| Converts methods for rendering and parsing the result of a page into a 'Dlg' step. -} showPage :: Monad m => Page m -> ServerPartT m a -> Dlg m a showPage p r = Step p (Action (fmap Done r)) {-| A 'DialogSession' represents a single user's active dialogs, which are retained for an entire session. A reaper thread clears up sessions that have not been touched for some session timeout, so each session also stores the last time it was touched. In addition, each session is associated with a fixed client address and cannot be used from a different client, which avoid hijacking. -} data DialogSession m = DialogSession { client :: String, lastTouched :: MVar UTCTime, dialogs :: MVar (Map Int (Dlg m ())) } {-| A 'DialogManager' is responsible for maintaining the state for 'Dlg' sequences for all users. To do this, it keeps for each user a session object encapsulating their dialogs, and associates each user with a dialog using cookies. -} data DialogManager m = DialogManager { sessions :: MVar (Map Int (DialogSession m)), open :: MVar Bool } {-| Determine whether a session is still valid or not. -} goodSession :: NominalDiffTime -> (Int, DialogSession m) -> IO Bool goodSession timeout (_, DialogSession _ tref _) = do st <- readMVar tref ct <- getCurrentTime return (diffUTCTime ct st <= timeout) {-| Monadic while statement, for convenience. -} whileM :: Monad m => m Bool -> m a -> m () whileM cond action = do b <- cond if b then action >> whileM cond action else return () {-| Create a new 'DialogManager' to manage a set of dialogs in the web application. This also spawns the session reaper, which cleans up sessions that haven't been touched for a given time period. -} makeDialogManager :: NominalDiffTime -> IO (DialogManager m) makeDialogManager timeout = do oref <- newMVar True sref <- newMVar M.empty forkIO $ whileM (readMVar oref) $ do threadDelay 5000 sessionMap <- takeMVar sref goodSessions <- filterM (goodSession timeout) (M.assocs sessionMap) putMVar sref (M.fromList goodSessions) return (DialogManager { sessions = sref, open = oref }) {-| Closes a DialogManager, which will cause it to cease accepting any incoming requests, and also to terminate the session reaper thread. -} closeDialogManager :: DialogManager m -> IO () closeDialogManager (DialogManager _ oref) = swapMVar oref False >> return () {-| Given a 'Map' with a key type that can be randomly chosen, returns a key that is not currently in the map. -} uniqueKey :: (Random k, Ord k) => Map k a -> IO k uniqueKey m = do k <- randomIO if M.member k m then uniqueKey m else return k {-| Adds a 'DialogSession' and associated cookie. This always sets a new blank session, so should only be used when there is no session already. -} addDialogSession :: MonadIO m => DialogManager m -> ServerPartT m (DialogSession m) addDialogSession (DialogManager sref _) = do rq <- askRq (k, session) <- liftIO $ do smap <- takeMVar sref k <- uniqueKey smap let (c, _) = rqPeer rq ct <- getCurrentTime tref <- newMVar ct dref <- newMVar M.empty let session = DialogSession c tref dref putMVar sref (M.insert k session smap) return (k, session) addCookie (-1) (mkCookie "dlg-sid" (show k)) return session {-| Ensures that there is a 'DialogSession' for the current user, and returns it. Adds a blank one if necessary. This also updates the last touched time for the session, preventing it from being removed by the reaper thread for a while. -} getDialogSession :: MonadIO m => DialogManager m -> ServerPartT m (DialogSession m) getDialogSession dmgr@(DialogManager sref oref) = do open <- liftIO $ readMVar oref unless open mzero rq <- askRq msid <- getDataFn (lookCookieValue "dlg-sid") case msid of Nothing -> addDialogSession dmgr Just sid -> do smap <- liftIO $ readMVar sref case M.lookup (read sid) smap of Nothing -> addDialogSession dmgr Just s@(DialogSession { lastTouched = tref }) -> if fst (rqPeer rq) /= client s then addDialogSession dmgr else liftIO $ do ct <- getCurrentTime swapMVar tref ct return s {-| The 'dialog' function builds a 'ServerPartT' that handles a given dialog. In general, it can be combined in normal ways with guards and such, so long as changes in the request parameters won't cause it to be missed when future requests are made in the same dialog. -} dialog :: MonadIO m => DialogManager m -> Dlg m () -> ServerPartT m Response dialog dmgr@(DialogManager sref oref) dlg = do (DialogSession _ _ dref) <- getDialogSession dmgr cont dref `mplus` this dref dlg where cont dref = withDataFn (lookRead "dlg-dlgid") $ \dlgid -> do dlgs <- liftIO $ readMVar dref rq <- askRq case M.lookup dlgid dlgs of Nothing -> mzero Just d -> this dref d this dref (Done _) = mzero this dref (Action a) = a >>= this dref this dref (Step p f) = do dlgs <- liftIO $ takeMVar dref k <- liftIO $ uniqueKey dlgs liftIO $ putMVar dref (M.insert k f dlgs) p k