From cbb13e474d3ae15378a5fb2fd67501ec7b5157da Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Hamal=20Dvo=C5=99=C3=A1k?= <mordae@anilinux.org>
Date: Sun, 27 Sep 2020 15:18:21 +0200
Subject: [PATCH] Redesign configuration handling even more
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Jan Hamal Dvořák <mordae@anilinux.org>
---
 hikaru.cabal         |   9 ++--
 lib/Hikaru.hs        |   2 +
 lib/Hikaru/Action.hs |  74 +++++++++++-------------------
 lib/Hikaru/Config.hs | 105 +++++++++++++++++++++++++++++++++++++++++++
 package.yaml         |   5 ++-
 test/Hikaru/Demo.hs  |  18 ++++----
 6 files changed, 152 insertions(+), 61 deletions(-)
 create mode 100644 lib/Hikaru/Config.hs

diff --git a/hikaru.cabal b/hikaru.cabal
index 6a41019..3d892b4 100644
--- a/hikaru.cabal
+++ b/hikaru.cabal
@@ -4,7 +4,7 @@ cabal-version: 1.12
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: 5ac50160b1396ef3e336b16f172ed703e020f37d729d4384049493c620bd5167
+-- hash: 8bbf377869281f273f43485c46ccce646be610521dbf18fa016b0762e07d767f
 
 name:           hikaru
 version:        0.1.0.0
@@ -35,6 +35,7 @@ library
   exposed-modules:
       Hikaru
       Hikaru.Action
+      Hikaru.Config
       Hikaru.CSRF
       Hikaru.Dispatch
       Hikaru.Form
@@ -47,7 +48,7 @@ library
       Paths_hikaru
   hs-source-dirs:
       lib
-  default-extensions: BlockArguments DataKinds DefaultSignatures DeriveGeneric FlexibleInstances GADTs GeneralizedNewtypeDeriving KindSignatures LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving UndecidableInstances
+  default-extensions: BlockArguments DataKinds DefaultSignatures DeriveGeneric FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving KindSignatures LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving UndecidableInstances
   ghc-options: -Wall -Wcompat
   build-depends:
       aeson >=1.4 && <1.5
@@ -60,6 +61,7 @@ library
     , cryptonite >=0.26 && <0.27
     , http-types >=0.12 && <0.13
     , lucid >=2.9 && <2.10
+    , memory >=0.15 && <0.16
     , mtl >=2.2 && <2.3
     , relude >=0.7 && <0.8
     , resourcet >=1.2 && <1.3
@@ -82,7 +84,7 @@ test-suite spec
       Paths_hikaru
   hs-source-dirs:
       test
-  default-extensions: BlockArguments DataKinds DefaultSignatures DeriveGeneric FlexibleInstances GADTs GeneralizedNewtypeDeriving KindSignatures LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving UndecidableInstances
+  default-extensions: BlockArguments DataKinds DefaultSignatures DeriveGeneric FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving KindSignatures LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving UndecidableInstances
   ghc-options: -Wall -Wcompat -threaded -rtsopts -with-rtsopts=-N
   cpp-options: -DTEST
   build-tool-depends:
@@ -100,6 +102,7 @@ test-suite spec
     , hspec
     , http-types >=0.12 && <0.13
     , lucid >=2.9 && <2.10
+    , memory >=0.15 && <0.16
     , mtl >=2.2 && <2.3
     , relude >=0.7 && <0.8
     , resourcet >=1.2 && <1.3
diff --git a/lib/Hikaru.hs b/lib/Hikaru.hs
index 7556292..6e38b2e 100644
--- a/lib/Hikaru.hs
+++ b/lib/Hikaru.hs
@@ -14,6 +14,7 @@ module Hikaru
   (
   -- * Exported Modules
     module Hikaru.Action
+  , module Hikaru.Config
   , module Hikaru.CSRF
   , module Hikaru.Dispatch
   , module Hikaru.Form
@@ -25,6 +26,7 @@ module Hikaru
   )
 where
   import Hikaru.Action
+  import Hikaru.Config
   import Hikaru.CSRF
   import Hikaru.Dispatch
   import Hikaru.Form
diff --git a/lib/Hikaru/Action.hs b/lib/Hikaru/Action.hs
index 107b35f..464625e 100644
--- a/lib/Hikaru/Action.hs
+++ b/lib/Hikaru/Action.hs
@@ -89,8 +89,6 @@ module Hikaru.Action
   , dropCaches
 
   -- ** Configuration
-  , updateConfig
-  , updateConfigFromEnv
   , getConfigMaybe
   , getConfigDefault
 
@@ -121,6 +119,7 @@ where
   import Data.Dynamic
   import Data.List
   import Data.String.Conversions
+  import Hikaru.Config
   import Hikaru.Media
   import Hikaru.Types
   import Lucid
@@ -128,7 +127,6 @@ where
   import Network.HTTP.Types.Method
   import Network.HTTP.Types.Status
   import Network.Wai
-  import System.Environment
   import System.IO.Unsafe
   import Web.Cookie
 
@@ -195,7 +193,7 @@ where
   data ActionEnv
     = ActionEnv
       { aeRequest      :: Request
-      , aeConfig       :: IORef (Map.Map Text String)
+      , aeConfig       :: Config
       , aeBody         :: IORef RequestBody
       , aeRespStatus   :: IORef Status
       , aeRespHeaders  :: IORef ResponseHeaders
@@ -215,9 +213,9 @@ where
   --
   -- Whole operation is bracketed to ensure all finalizers are run.
   --
-  respond :: (ActionEnv -> IO ()) -> Application
-  respond run req resp = do
-    env <- makeActionEnv req
+  respond :: Config -> (ActionEnv -> IO ()) -> Application
+  respond cfg run req resp = do
+    env <- makeActionEnv cfg req
 
     bracket_ (return ()) (finalize env) do
       _   <- run env
@@ -263,21 +261,23 @@ where
 
 
   -- |
-  -- Create the initial action environment from the 'Request'.
+  -- Create an initial action environment to handle given 'Request'.
   --
-  makeActionEnv :: Request -> IO ActionEnv
-  makeActionEnv req =
-    ActionEnv <$> pure req
-              <*> newIORef Map.empty
-              <*> newIORef BodyUnparsed
-              <*> newIORef status200
-              <*> newIORef []
-              <*> newIORef (\st hs -> responseLBS st hs "")
-              <*> newIORef (return ())
-              <*> newIORef (10 * 1024 * 1024)
-              <*> newIORef 0
-              <*> newIORef []
-              <*> newIORef Map.empty
+  makeActionEnv :: Config -> Request -> IO ActionEnv
+  makeActionEnv cfg req = do
+    aeRequest     <- pure req
+    aeConfig      <- pure cfg
+    aeBody        <- newIORef BodyUnparsed
+    aeRespStatus  <- newIORef status200
+    aeRespHeaders <- newIORef []
+    aeRespMaker   <- newIORef (\st hs -> responseLBS st hs "")
+    aeFinalize    <- newIORef (return ())
+    aeBodyLimit   <- newIORef (10 * 1024 * 1024)
+    aeBodyCounter <- newIORef 0
+    aeLanguages   <- newIORef []
+    aeCache       <- newIORef Map.empty
+
+    return ActionEnv{..}
 
 
   -- Inspecting Request ------------------------------------------------------
@@ -1096,42 +1096,22 @@ where
   -- Configuration -----------------------------------------------------------
 
 
-  -- |
-  -- Update configuration using supplied key-value pairs.
-  --
-  -- Request starts with an empty configuration.
-  --
-  updateConfig :: (MonadAction m) => [(Text, String)] -> m ()
-  updateConfig = modifyActionField aeConfig . Map.union . Map.fromList
-
-
-  -- |
-  -- Update configuration using the program environment.
-  --
-  updateConfigFromEnv :: (MonadAction m) => m ()
-  updateConfigFromEnv = do
-    env <- liftIO $ getEnvironment
-
-    let conv (k, v) = (cs k, v)
-     in updateConfig (map conv env)
-
-
   -- |
   -- Lookup a configuration value.
   --
-  getConfigMaybe :: (MonadAction m, Read a) => Text -> m (Maybe a)
+  getConfigMaybe :: (MonadAction m, Param a) => Text -> m (Maybe a)
   getConfigMaybe name = do
-    cfg <- getActionField aeConfig
-    return $ readMaybe =<< Map.lookup name cfg
+    ActionEnv{aeConfig} <- getActionEnv
+    return $ fromParam =<< Map.lookup name aeConfig
 
 
   -- |
   -- Lookup a configuration value with a fallback to return if not found.
   --
-  getConfigDefault :: (MonadAction m, Read a) => Text -> a -> m a
+  getConfigDefault :: (MonadAction m, Param a) => Text -> a -> m a
   getConfigDefault name value = do
-    cfg <- getActionField aeConfig
-    return $ fromMaybe value $ readMaybe =<< Map.lookup name cfg
+    ActionEnv{aeConfig} <- getActionEnv
+    return $ fromMaybe value $ fromParam =<< Map.lookup name aeConfig
 
 
   -- Finalizing --------------------------------------------------------------
diff --git a/lib/Hikaru/Config.hs b/lib/Hikaru/Config.hs
new file mode 100644
index 0000000..fb297e6
--- /dev/null
+++ b/lib/Hikaru/Config.hs
@@ -0,0 +1,105 @@
+{-|
+Module      :  Hikaru.Config
+Copyright   :  Jan Hamal Dvořák
+License     :  AGPL-3
+
+Maintainer  :  mordae@anilinux.org
+Stability   :  unstable
+Portability :  non-portable (ghc)
+
+This module provides means to read configuration from environment and files.
+
+Example:
+
+@
+  base <- 'configDefault'
+  file <- 'configFromFile' \"site.env\"
+  env  <- 'configFromEnv'
+
+  let cfg = env <> file <> base
+@
+-}
+
+module Hikaru.Config
+  ( Config
+  , configFromEnv
+  , configFromFile
+  , configDefault
+
+  -- * Secrets
+  , generateSecret
+  )
+where
+  import Relude hiding (lines)
+
+  import qualified Data.Map as Map
+
+  import Crypto.Random.Entropy
+  import Data.ByteArray.Encoding
+  import Data.List (lines, span)
+  import Data.String.Conversions
+  import System.Environment
+
+
+  -- |
+  -- Website configuration to be passed to the action.
+  --
+  type Config = Map.Map Text Text
+
+
+  -- |
+  -- Read configuration from program environment.
+  --
+  configFromEnv :: (MonadIO m) => m Config
+  configFromEnv = Map.fromList <$> map conv <$> liftIO getEnvironment
+    where conv (k, v) = (cs k, cs v)
+
+
+  -- |
+  -- Read configuration from file.
+  --
+  -- To read configuration from a file and then update it from the environment
+  -- use "<>" like this:
+  --
+  -- @
+  -- def <- 'configFromFile' \"site.env\"
+  -- env <- 'configFromEnv'
+  --
+  -- let cfg = env <> def
+  -- @
+  --
+  configFromFile :: (MonadIO m) => FilePath -> m Config
+  configFromFile path = Map.fromList <$> map parse <$> lines <$> readFile path
+    where
+      parse       = conv . span (/= '=')
+      conv (k, v) = (cs k, cs $ drop 1 v)
+
+
+  -- |
+  -- Generate default configuration with keys required by Hikaru.
+  --
+  -- This includes @CSRF_SECRET@ which should be set to something persistent,
+  -- but if it's not, it must be set to a random value upon startup at least.
+  --
+  configDefault :: (MonadIO m) => m Config
+  configDefault = do
+    secret <- generateSecret 16
+
+    return $ Map.fromList [ ("CSRF_SECRET",   secret)
+                          , ("CSRF_VALIDITY", "86400")
+                          ]
+
+
+  -- |
+  -- Generate a random base64-encoded secret of given length
+  -- (in decoded bytes).
+  --
+  generateSecret :: (MonadIO m) => Int -> m Text
+  generateSecret n = do
+    (bstr :: ByteString) <- liftIO $ getEntropy n
+
+    let (bstr64 :: ByteString) = convertToBase Base64 bstr
+     in return (cs bstr64)
+
+
+-- vim:set ft=haskell sw=2 ts=2 et:
diff --git a/package.yaml b/package.yaml
index 0680073..c8d9d78 100644
--- a/package.yaml
+++ b/package.yaml
@@ -27,6 +27,7 @@ default-extensions:
   - DataKinds
   - DefaultSignatures
   - DeriveGeneric
+  - FlexibleContexts
   - FlexibleInstances
   - GADTs
   - GeneralizedNewtypeDeriving
@@ -45,7 +46,6 @@ default-extensions:
 dependencies:
   - aeson              >= 1.4  && <1.5
   - base               >= 4.13 && <4.14
-  - relude             >= 0.7  && <0.8
   - binary             >= 0.8  && <0.9
   - bytestring         >= 0.10 && <0.11
   - case-insensitive   >= 1.2  && <1.3
@@ -54,7 +54,9 @@ dependencies:
   - cryptonite         >= 0.26 && <0.27
   - http-types         >= 0.12 && <0.13
   - lucid              >= 2.9  && <2.10
+  - memory             >= 0.15 && <0.16
   - mtl                >= 2.2  && <2.3
+  - relude             >= 0.7  && <0.8
   - resourcet          >= 1.2  && <1.3
   - string-conversions >= 0.4  && <0.5
   - text               >= 1.2  && <1.3
@@ -70,6 +72,7 @@ library:
   exposed-modules:
     - Hikaru
     - Hikaru.Action
+    - Hikaru.Config
     - Hikaru.CSRF
     - Hikaru.Dispatch
     - Hikaru.Form
diff --git a/test/Hikaru/Demo.hs b/test/Hikaru/Demo.hs
index 437b7b1..1510455 100644
--- a/test/Hikaru/Demo.hs
+++ b/test/Hikaru/Demo.hs
@@ -100,24 +100,22 @@ where
   makeDemo :: IO Application
   makeDemo = do
     model <- makeModelEnv 0
-    return $ makeApplication model
+    cfg   <- configFromEnv
+    return $ makeApplication model cfg
 
 
-  runAction :: ModelEnv -> Action () -> Application
-  runAction me act = do
-    respond \ae -> do
+  runAction :: ModelEnv -> Config -> Action () -> Application
+  runAction me cfg act = do
+    respond cfg \ae -> do
       runReaderT (unAction act) (DemoEnv ae me)
 
 
-  makeApplication :: ModelEnv -> Application
-  makeApplication me = do
-    dispatch (runAction me) do
+  makeApplication :: ModelEnv -> Config -> Application
+  makeApplication me cfg = do
+    dispatch (runAction me cfg) do
       -- Register nicer 404 error handler.
       handler NotFound handleNotFound
 
-      -- Read configuration from environment.
-      wrapActions (updateConfigFromEnv >>)
-
       -- Negotiate content for the root page.
       route $ getRootHtmlR <$ get <* offerHTML
       route $ getRootTextR <$ get <* offerText
-- 
GitLab