summaryrefslogtreecommitdiff
path: root/Que/Host.hs
diff options
context:
space:
mode:
Diffstat (limited to 'Que/Host.hs')
-rw-r--r--Que/Host.hs254
1 files changed, 254 insertions, 0 deletions
diff --git a/Que/Host.hs b/Que/Host.hs
new file mode 100644
index 0000000..5b51dba
--- /dev/null
+++ b/Que/Host.hs
@@ -0,0 +1,254 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Interprocess communication
+--
+-- Prior art:
+-- - <https://github.com/jb55/httpipe>
+-- - <https://patchbay.pub>
+-- - <https://github.com/hargettp/courier>
+-- - sorta: <https://ngrok.com/> and <https://localtunnel.github.io/www/>
+--
+-- : exe que-server
+--
+-- : dep async
+-- : dep envy
+-- : dep protolude
+-- : dep scotty
+-- : dep stm
+-- : dep unagi-chan
+-- : dep unordered-containers
+module Que.Host
+ ( main,
+ )
+where
+
+import Alpha hiding (gets, modify, poll)
+import qualified Control.Concurrent.Go as Go
+import qualified Control.Concurrent.STM as STM
+import qualified Control.Exception as Exception
+import Control.Monad.Reader (MonadTrans)
+import qualified Data.ByteString.Builder.Extra as Builder
+import qualified Data.ByteString.Lazy as BSL
+import Data.HashMap.Lazy (HashMap)
+import qualified Data.HashMap.Lazy as HashMap
+import qualified Data.Text.Encoding as Encoding
+import qualified Data.Text.Lazy as Text.Lazy
+import qualified Data.Text.Lazy.IO as Text.Lazy.IO
+import qualified Network.HTTP.Types.Status as Http
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import Network.Wai.Middleware.RequestLogger
+ ( logStdout,
+ )
+import qualified System.Envy as Envy
+import qualified Web.Scotty.Trans as Scotty
+import qualified Prelude
+
+{-# ANN module ("HLint: ignore Reduce duplication" :: Prelude.String) #-}
+
+main :: IO ()
+main = Exception.bracket startup shutdown <| uncurry Warp.run
+ where
+ startup = Envy.decodeWithDefaults Envy.defConfig >>= \c -> do
+ sync <- STM.newTVarIO initialAppState
+ let runActionToIO m = runReaderT (runApp m) sync
+ waiapp <- Scotty.scottyAppT runActionToIO <| routes c
+ putText "*"
+ putText "Que.Host"
+ putText <| "port: " <> (show <| quePort c)
+ putText <| "skey: " <> (show <| queSkey c)
+ return (port c, waiapp)
+ shutdown :: a -> IO a
+ shutdown = pure . identity
+
+newtype App a
+ = App
+ { runApp :: ReaderT (STM.TVar AppState) IO a
+ }
+ deriving
+ ( Applicative,
+ Functor,
+ Monad,
+ MonadIO,
+ MonadReader
+ (STM.TVar AppState)
+ )
+
+newtype AppState
+ = AppState
+ { ques :: HashMap Namespace Quebase
+ }
+
+initialAppState :: AppState
+initialAppState = AppState {ques = mempty}
+
+data Config
+ = Config
+ { -- | QUE_PORT
+ quePort :: Warp.Port,
+ -- | QUE_SKEY
+ queSkey :: FilePath
+ }
+ deriving (Generic, Show)
+
+instance Envy.DefConfig Config where
+ defConfig = Config 3000 "/run/skey/que-admin"
+
+instance Envy.FromEnv Config
+
+routes :: Config -> Scotty.ScottyT Text.Lazy.Text App ()
+routes cfg = do
+ Scotty.middleware logStdout
+ let quepath = "^\\/([[:alnum:]_-]+)\\/([[:alnum:]._/-]*)$"
+ let namespace = "^\\/([[:alnum:]_-]+)\\/?$" -- matches '/ns' and '/ns/' but not '/ns/path'
+
+ -- GET /index.html
+ Scotty.get (Scotty.literal "/index.html") <| Scotty.redirect "/_/index"
+ Scotty.get (Scotty.literal "/") <| Scotty.redirect "/_/index"
+ -- GET /_/dash
+ Scotty.get (Scotty.literal "/_/dash") <| do
+ authkey <- fromMaybe "" </ Scotty.header "Authorization"
+ adminkey <- liftIO <| lchomp </ Text.Lazy.IO.readFile (queSkey cfg)
+ if authkey == adminkey
+ then do
+ d <- app <| gets ques
+ Scotty.json d
+ else do
+ Scotty.status Http.methodNotAllowed405
+ Scotty.text "not allowed"
+ -- Namespace management
+ Scotty.matchAny (Scotty.regex namespace) <| do
+ Scotty.status Http.notImplemented501
+ Scotty.text "namespace management coming soon"
+ -- GET que
+ --
+ -- Receive a value from a que. Blocks until a value is received,
+ -- then returns. If 'poll=true', then stream data from the Que to the
+ -- client.
+ Scotty.get (Scotty.regex quepath) <| do
+ (ns, qp) <- extract
+ guardNs ns ["pub", "_"]
+ app . modify <| upsertNamespace ns
+ q <- app <| que ns qp
+ poll <- Scotty.param "poll" !: (pure . const False)
+ if poll
+ then Scotty.stream <| streamQue q
+ else do
+ r <- liftIO <| Go.read q
+ Scotty.html <| fromStrict <| Encoding.decodeUtf8 r
+ -- POST que
+ --
+ -- Put a value on a que. Returns immediately.
+ Scotty.post (Scotty.regex quepath) <| do
+ authkey <- fromMaybe "" </ Scotty.header "Authorization"
+ adminkey <- liftIO <| lchomp </ Text.Lazy.IO.readFile (queSkey cfg)
+ (ns, qp) <- extract
+ -- Only allow my IP or localhost to publish to '_' namespace
+ when ("_" == ns && authkey /= adminkey)
+ <| Scotty.status Http.methodNotAllowed405
+ >> Scotty.text "not allowed: _ is a reserved namespace"
+ >> Scotty.finish
+ guardNs ns ["pub", "_"]
+ -- passed all auth checks
+ app . modify <| upsertNamespace ns
+ q <- app <| que ns qp
+ qdata <- Scotty.body
+ _ <- liftIO <| Go.write q <| BSL.toStrict qdata
+ return ()
+
+-- | Given `guardNs ns whitelist`, if `ns` is not in the `whitelist`
+-- list, return a 405 error.
+guardNs :: Text.Lazy.Text -> [Text.Lazy.Text] -> Scotty.ActionT Text.Lazy.Text App ()
+guardNs ns whitelist = when (not <| ns `elem` whitelist) <| do
+ Scotty.status Http.methodNotAllowed405
+ Scotty.text
+ <| "not allowed: use 'pub' namespace or signup to protect '"
+ <> ns
+ <> "' at https://que.run"
+ Scotty.finish
+
+-- | recover from a scotty-thrown exception.
+(!:) ::
+ -- | action that might throw
+ Scotty.ActionT Text.Lazy.Text App a ->
+ -- | a function providing a default response instead
+ (Text.Lazy.Text -> Scotty.ActionT Text.Lazy.Text App a) ->
+ Scotty.ActionT Text.Lazy.Text App a
+(!:) = Scotty.rescue
+
+-- | Forever write the data from 'Que' to 'Wai.StreamingBody'.
+streamQue :: Que -> Wai.StreamingBody
+streamQue q write _ = loop q
+ where
+ loop c =
+ Go.read c
+ >>= (write . Builder.byteStringInsert)
+ >> loop c
+
+-- | Gets the thing from the Hashmap. Call's 'error' if key doesn't exist.
+grab :: (Eq k, Hashable k) => k -> HashMap k v -> v
+grab = flip (HashMap.!)
+
+-- | Inserts the namespace in 'AppState' if it doesn't exist.
+upsertNamespace :: Namespace -> AppState -> AppState
+upsertNamespace ns as =
+ if HashMap.member ns (ques as)
+ then as
+ else as {ques = HashMap.insert ns mempty (ques as)}
+
+-- | Inserts the que at the proper 'Namespace' and 'Quepath'.
+insertQue :: Namespace -> Quepath -> Que -> AppState -> AppState
+insertQue ns qp q as = as {ques = newQues}
+ where
+ newQues = HashMap.insert ns newQbase (ques as)
+ newQbase = HashMap.insert qp q <| grab ns <| ques as
+
+extract :: Scotty.ActionT Text.Lazy.Text App (Namespace, Quepath)
+extract = do
+ ns <- Scotty.param "1"
+ path <- Scotty.param "2"
+ return (ns, path)
+
+-- | A synonym for 'lift' in order to be explicit about when we are
+-- operating at the 'App' layer.
+app :: MonadTrans t => App a -> t App a
+app = lift
+
+-- | Get something from the app state
+gets :: (AppState -> b) -> App b
+gets f = ask >>= liftIO . STM.readTVarIO >>= return </ f
+
+-- | Apply a function to the app state
+modify :: (AppState -> AppState) -> App ()
+modify f = ask >>= liftIO . atomically . flip STM.modifyTVar' f
+
+-- | housing for a set of que paths
+type Namespace = Text.Lazy.Text
+
+-- | a que is just a channel of bytes
+type Que = Go.Channel Message
+
+-- | any path can serve as an identifier for a que
+type Quepath = Text
+
+-- | any opaque data
+type Message = ByteString
+
+-- | a collection of ques
+type Quebase = HashMap Quepath Que
+
+-- | Lookup or create a que
+que :: Namespace -> Quepath -> App Que
+que ns qp = do
+ _ques <- gets ques
+ let qbase = grab ns _ques
+ queExists = HashMap.member qp qbase
+ if queExists
+ then return <| grab qp qbase
+ else do
+ c <- liftIO <| Go.chan 1
+ modify (insertQue ns qp c)
+ gets ques /> grab ns /> grab qp