Settings.hs 9.87 KB
Newer Older
1
{-| 
2 3 4 5 6 7 8 9 10 11 12
Module      : Gargantext.API.Settings
Description : Settings of the API (Server and Client)
Copyright   : (c) CNRS, 2017-Present
License     : AGPL + CECILL v3
Maintainer  : team@gargantext.org
Stability   : experimental
Portability : POSIX
-}

{-# OPTIONS_GHC -fno-warn-name-shadowing #-}

13 14 15 16 17 18 19 20 21
{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE DeriveGeneric       #-}
{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE FlexibleInstances   #-}
{-# LANGUAGE NoImplicitPrelude   #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell     #-}
22 23 24 25

module Gargantext.API.Settings
    where

26
import System.Directory
27 28 29
import System.Log.FastLogger
import GHC.Enum
import GHC.Generics (Generic)
30
import Prelude (Bounded(), fail)
31
import System.Environment (lookupEnv)
32 33 34
import System.IO (FilePath, hClose)
import System.IO.Temp (withTempFile)
import System.FileLock (tryLockFile, unlockFile, SharedExclusive(Exclusive))
35 36 37
import Database.PostgreSQL.Simple (Connection, connect)
import Network.HTTP.Client (Manager)
import Network.HTTP.Client.TLS (newTlsManager)
38

39
import Data.Aeson
40 41 42 43
import Data.Maybe (fromMaybe)
import Data.Either (either)
import Data.Text
import Data.Text.Encoding (encodeUtf8)
44 45
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as L
46 47

import Servant
48 49
import Servant.Client (BaseUrl, parseBaseUrl)
import Servant.Job.Async (newJobEnv, defaultSettings)
50 51 52 53
import Web.HttpApiData (parseUrlPiece)
import qualified Jose.Jwk as Jose
import qualified Jose.Jwa as Jose

54
import Control.Concurrent
55
import Control.Debounce (mkDebounce, defaultDebounceSettings, debounceFreq, debounceAction)
56
import Control.Exception (finally)
57
import Control.Monad.Logger
58
import Control.Monad.Reader
59 60
import Control.Lens
import Gargantext.Prelude
61
import Gargantext.Database.Utils (databaseParameters, HasConnection(..), Cmd', runCmd)
62
import Gargantext.API.Ngrams (NgramsRepo, HasRepoVar(..), HasRepoSaver(..), HasRepo(..), RepoEnv(..), r_version, saveRepo, initRepo, renv_var, renv_lock)
63
import Gargantext.API.Orchestrator.Types
64

65
type PortNumber = Int
66 67 68 69 70 71 72 73

data SendEmailType = SendEmailViaAws
                   | LogEmailToConsole
                   | WriteEmailToFile
    deriving (Show, Read, Enum, Bounded, Generic)


data Settings = Settings
74 75
    { _allowedOrigin   :: ByteString   -- allowed origin for CORS
    , _allowedHost     :: ByteString   -- allowed host for CORS
76
    , _appPort         :: PortNumber
77
    , _logLevelLimit   :: LogLevel -- log level from the monad-logger package
78 79
--    , _dbServer        :: Text
--    ^ this is not used yet
80
    , _jwtSecret       :: Jose.Jwk -- key from the jose-jwt package
81
    , _sendLoginEmails :: SendEmailType
82
    , _scrapydUrl      :: BaseUrl
83
    , _fileFolder      :: FilePath
84 85 86 87
    }

makeLenses ''Settings

88 89 90
class HasSettings env where
  settings :: Getter env Settings

91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106

parseJwk :: Text -> Jose.Jwk
parseJwk secretStr = jwk
    where
        secretBs = encodeUtf8 secretStr
        jwk      = Jose.SymmetricJwk secretBs 
                                     Nothing 
                                     Nothing 
                                     (Just $ Jose.Signed Jose.HS256)

devSettings :: Settings
devSettings = Settings
    { _allowedOrigin = "http://localhost:8008"
    , _allowedHost = "localhost:3000"
    , _appPort = 3000
    , _logLevelLimit = LevelDebug
107
--    , _dbServer = "localhost"
108 109 110 111 112
    -- generate with dd if=/dev/urandom bs=1 count=32 | base64
    -- make sure jwtSecret differs between development and production, because you do not want
    -- your production key inside source control.
    , _jwtSecret = parseJwk "MVg0YAPVSPiYQc/qIs/rV/X32EFR0zOJWfHFgMbszMw="
    , _sendLoginEmails = LogEmailToConsole
113
    , _scrapydUrl = fromMaybe (panic "Invalid scrapy URL") $ parseBaseUrl "http://localhost:6800"
114
    , _fileFolder = "data"
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
    }



reqSetting :: FromHttpApiData a => Text -> IO a
reqSetting name = do
    e <- fromMaybe (panic $ "Missing " <> name) <$> lookupEnv (unpack name)
    pure $ either (panic $ "Unable to parse " <> name) identity $ parseUrlPiece $ pack e

optSetting :: FromHttpApiData a => Text -> a -> IO a
optSetting name d = do
    me <- lookupEnv (unpack name)
    case me of
        Nothing -> pure d
        Just e -> pure $ either (panic $ "Unable to parse " <> name) identity $ parseUrlPiece $ pack e

--settingsFromEnvironment :: IO Settings
--settingsFromEnvironment =
--    Settings <$> (encodeUtf8 <$> reqSetting "ALLOWED_ORIGIN")
--             <*> (encodeUtf8 <$> reqSetting "ALLOWED_HOST")
--             <*> optSetting "PORT" 3000
--             <*> (parseLogLevel <$> optSetting "LOG_LEVEL" "warn")
--             <*> reqSetting "DB_SERVER"
--             <*> (parseJwk <$> reqSetting "JWT_SECRET")
--             <*> optSetting "SEND_EMAIL" SendEmailViaAws

141
data FireWall = FireWall { unFireWall :: Bool }
142 143

data Env = Env
144 145 146 147 148 149 150
  { _env_settings :: !Settings
  , _env_logger   :: !LoggerSet
  , _env_conn     :: !Connection
  , _env_repo     :: !RepoEnv
  , _env_manager  :: !Manager
  , _env_self_url :: !BaseUrl
  , _env_scrapers :: !ScrapersEnv
151 152
  }
  deriving (Generic)
153 154

makeLenses ''Env
155

156 157 158
instance HasConnection Env where
  connection = env_conn

159
instance HasRepoVar Env where
160
  repoVar = repoEnv . repoVar
161

162
instance HasRepoSaver Env where
163 164 165 166 167 168 169
  repoSaver = repoEnv . repoSaver

instance HasRepo Env where
  repoEnv = env_repo

instance HasSettings Env where
  settings = env_settings
170

171 172 173 174 175 176 177
data MockEnv = MockEnv
  { _menv_firewall :: !FireWall
  }
  deriving (Generic)

makeLenses ''MockEnv

178
-- | TODO add this path in Settings
179 180 181
repoSnapshot :: FilePath
repoSnapshot = "repo.json"

182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
-- | TODO add hard coded file in Settings
-- This assumes we own the lock on repoSnapshot.
repoSaverAction :: ToJSON a => a -> IO ()
repoSaverAction a = do
  withTempFile "." "tmp-repo.json" $ \fp h -> do
    -- printDebug "repoSaverAction" fp
    L.hPut h $ encode a
    hClose h
    renameFile fp repoSnapshot

mkRepoSaver :: MVar NgramsRepo -> IO (IO ())
mkRepoSaver repo_var = mkDebounce settings
  where
    settings = defaultDebounceSettings
                 { debounceFreq   = 1000000 -- 1 second
                 , debounceAction = withMVar repo_var repoSaverAction
198
                   -- Here this not only `readMVar` but `takeMVar`.
199 200 201 202 203 204
                   -- Namely while repoSaverAction is saving no other change
                   -- can be made to the MVar.
                   -- This might be not efficent and thus reconsidered later.
                   -- However this enables to safely perform a *final* save.
                   -- See `cleanEnv`.
                   -- Future work:
205
                   -- Add a new MVar just for saving.
206 207 208 209
                 }

readRepoEnv :: IO RepoEnv
readRepoEnv = do
210
  -- Does file exist ? :: Bool
211
  repoFile <- doesFileExist repoSnapshot
212

213
  -- Is file not empty ? :: Bool
214 215
  repoExists <- if repoFile
             then (>0) <$> getFileSize repoSnapshot
216 217 218 219 220 221
             else pure False

  mlock <- tryLockFile repoSnapshot Exclusive
  lock <- maybe (panic "Repo file already locked") pure mlock

  mvar <- newMVar =<<
222 223 224
    if repoExists
      then do
        e_repo <- eitherDecodeFileStrict repoSnapshot
225
        repo   <- either fail pure e_repo
226
        let archive = repoSnapshot <> ".v" <> show (repo ^. r_version)
227
        copyFile repoSnapshot archive
228 229
        pure repo
      else
230
        pure initRepo
231

232 233
  saver <- mkRepoSaver mvar
  pure $ RepoEnv { _renv_var = mvar, _renv_saver = saver, _renv_lock = lock }
234

235 236 237 238 239 240
newEnv :: PortNumber -> FilePath -> IO Env
newEnv port file = do
  manager <- newTlsManager
  settings <- pure (devSettings & appPort .~ port) -- TODO read from 'file'
  when (port /= settings ^. appPort) $
    panic "TODO: conflicting settings of port"
241

242
  self_url <- parseBaseUrl $ "http://0.0.0.0:" <> show port
243 244
  param    <- databaseParameters file
  conn     <- connect param
245
  repo     <- readRepoEnv
246 247
  scrapers_env <- newJobEnv defaultSettings manager
  logger <- newStderrLoggerSet defaultBufSize
248

249
  pure $ Env
250 251 252
    { _env_settings   = settings
    , _env_logger     = logger
    , _env_conn       = conn
253
    , _env_repo       = repo
254 255 256
    , _env_manager    = manager
    , _env_scrapers   = scrapers_env
    , _env_self_url   = self_url
257
    }
258 259

data DevEnv = DevEnv
260 261 262
  { _dev_env_conn :: !Connection
  , _dev_env_repo :: !RepoEnv
  , _dev_env_settings :: !Settings
263 264 265 266 267 268 269 270
  }

makeLenses ''DevEnv

instance HasConnection DevEnv where
  connection = dev_env_conn

instance HasRepoVar DevEnv where
271
  repoVar = repoEnv . repoVar
272

273
instance HasRepoSaver DevEnv where
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309
  repoSaver = repoEnv . repoSaver

instance HasRepo DevEnv where
  repoEnv = dev_env_repo

instance HasSettings DevEnv where
  settings = dev_env_settings

cleanEnv :: HasRepo env => env -> IO ()
cleanEnv env = do
  r <- takeMVar (env ^. repoEnv . renv_var)
  repoSaverAction r
  unlockFile (env ^. repoEnv . renv_lock)

withDevEnv :: FilePath -> (DevEnv -> IO a) -> IO a
withDevEnv iniPath k = do
  env <- newDevEnv
  k env `finally` cleanEnv env

  where
    newDevEnv = do
      param <- databaseParameters iniPath
      conn  <- connect param
      repo  <- readRepoEnv
      pure $ DevEnv
        { _dev_env_conn = conn
        , _dev_env_repo = repo
        , _dev_env_settings = devSettings
        }

-- | Run Cmd Sugar for the Repl (GHCI)
runCmdRepl :: Show err => Cmd' DevEnv err a -> IO a
runCmdRepl f = withDevEnv "gargantext.ini" $ \env -> runCmdDev env f

runCmdReplServantErr :: Cmd' DevEnv ServantErr a -> IO a
runCmdReplServantErr = runCmdRepl
310 311 312 313 314 315 316

-- Use only for dev
-- In particular this writes the repo file after running
-- the command.
-- This function is constrained to the DevEnv rather than
-- using HasConnection and HasRepoVar.
runCmdDev :: Show err => DevEnv -> Cmd' DevEnv err a -> IO a
317
runCmdDev env f =
318
  (either (fail . show) pure =<< runCmd env f)
319 320
    `finally`
  runReaderT saveRepo env
321 322 323 324 325 326 327 328

-- Use only for dev
runCmdDevNoErr :: DevEnv -> Cmd' DevEnv () a -> IO a
runCmdDevNoErr = runCmdDev

-- Use only for dev
runCmdDevServantErr :: DevEnv -> Cmd' DevEnv ServantErr a -> IO a
runCmdDevServantErr = runCmdDev