git.mcksp
    1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
   21
   22
   23
   24
   25
   26
   27
   28
   29
   30
   31
   32
   33
   34
   35
   36
   37
   38
   39
   40
   41
   42
   43
   44
   45
   46
   47
   48
   49
   50
   51
   52
   53
   54
   55
   56
   57
   58
   59
   60
   61
   62
   63
   64
   65
   66
   67
   68
   69
   70
   71
   72
   73
   74
   75
   76
   77
   78
   79
   80
   81
   82
   83
   84
   85
   86
   87
   88
   89
   90
   91
   92
   93
   94
   95
   96
   97
   98
   99
  100
  101
  102
  103
  104
  105
  106
  107
  108
  109
  110
  111
  112
  113
  114
  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
  141
  142
  143
  144
  145
  146
  147
  148
  149
  150
  151
  152
  153
  154
  155
  156
  157
  158
  159
  160
  161
  162
  163
  164
  165
  166
  167
  168
  169
  170
  171
  172
  173
  174
  175
  176
  177
  178
  179
  180
  181
  182
  183
  184
  185
  186
  187
  188
  189
  190
  191
  192
  193
  194
  195
  196
  197
  198
  199
  200
  201
  202
  203
  204
  205
  206
  207
  208
  209
  210
module Framework
    ( safetyHeaders
    , redirectBack
    , getA
    , getAuth
    , postA
    , postAuth
    , render
    , partialRender
    , prepareRequest
    , limitHttpMethods
    , htmxRedirectMiddleware
    , isHtmx
    ) where

import Types
import Models
import qualified Web.Scotty.Trans as Scotty
import Web.Scotty.Trans (RoutePattern, redirect, request, html)
import Control.Monad.IO.Class (liftIO, MonadIO)
import Network.Wai ( Middleware, mapResponseHeaders, rawPathInfo,
    Request (..), responseStatus, responseLBS)
import qualified Data.Text as T
import qualified Lucid.Base as Lucid
import Control.Monad.Reader (Reader, lift, runReader, ask, runReaderT, asks)
import qualified Web
import Cookies
import qualified Repos.Users as Users
import qualified Repos.Stories as Stories
import Utils
import qualified Data.Text.Encoding as T
import Web.Scotty.Internal.Types (ActionT(..))
import Data.Maybe (fromMaybe, isJust)
import qualified Data.Text.Lazy as TL
import Colog (LogAction)
import qualified Data.ByteString as BS
import qualified Data.Aeson as JSON
import Network.HTTP.Types (parseMethod, Status (..), status400)
import Data.Time (diffUTCTime)
import qualified Data.Vault.Lazy as V
import Database.SQLite.Simple (Connection)
import Repos.Utils (CanDB)

type PreActionR a = ActionT App a
type PreAction = PreActionR ()

safetyHeaders :: Middleware
safetyHeaders app req resp = do
    app req $ resp . mapResponseHeaders (\h ->
        -- ("Content-Security-Policy", "default-src 'self';") :
        ("X-Content-Type-Options", "nosniff") :
        ("X-Frame-Options", "deny") :
        ("X-XSS-Protection", "0") : h)

redirectBack :: Monad m => ActionT m a
redirectBack = do
    req <- request
    let referer = TL.fromStrict . T.decodeUtf8 <$> requestHeaderReferer req
    redirect $ fromMaybe "/" referer

getCSRF :: PreActionR T.Text
getCSRF = do
    mCookieToken <- getCookie "csrf_token"
    case mCookieToken of
        Nothing -> do
            -- TODO crypto secure token
            setCookie "csrf_token" "abc123"
            return "abc123"
        Just cookieToken ->
            return cookieToken

basicHandler :: Action () -> PreAction
basicHandler handler = do
    csrf <- getCSRF
    path <- fmap (.rawPathInfo) request
    reqInfo <- getReqInfo
    let reqId = reqInfo.reqId
    let user = reqInfo.user
    env <- lift getEnv
    let ctx = Ctx reqId csrf (T.decodeUtf8 path)
    let envCtx = EnvCtx env ctx user
    scottyEnv <- ask
    let runSc = runReaderT handler.runAM scottyEnv
    liftIO $ runReaderT runSc.runHandler envCtx

basicAuthHandler :: ActionAuth () -> PreAction
basicAuthHandler handler = do
    csrf <- getCSRF
    path <- fmap (.rawPathInfo) request
    reqInfo <- getReqInfo
    let reqId = reqInfo.reqId
    user <- case reqInfo.user of
        Just u -> return u
        Nothing -> redirect "/login"
    env <- lift getEnv
    let ctx = Ctx reqId csrf (T.decodeUtf8 path)
    let envCtxAuth = EnvCtxAuth env ctx user
    scottyEnv <- ask
    let runSc = runReaderT handler.runAM scottyEnv
    liftIO $ runReaderT runSc.runHandler envCtxAuth

getReqInfo :: PreActionR ReqInfo
getReqInfo = do
    key <- lift $ asks (.reqInfoKey)
    v <- fmap (.vault) request
    let reqInfo = V.lookup key v
    case reqInfo of
        Just r -> return r
        -- TODO error handling
        Nothing -> return $ ReqInfo "" Nothing

getA :: RoutePattern -> Action () -> Server
getA route handler = do
    Scotty.get route $ do
        basicHandler handler

getAuth :: RoutePattern -> ActionAuth () -> Server
getAuth route handler = do
    Scotty.get route $ do
        basicAuthHandler handler

postA :: RoutePattern -> Action () -> Server
postA route handler = do
    Scotty.post route $ do
        basicHandler handler

postAuth :: RoutePattern -> ActionAuth () -> Server
postAuth route handler = do
    Scotty.post route $ do
        basicAuthHandler handler

render :: (HasUser m, HasCtx m, CanDB m, MonadIO m) =>
     Lucid.HtmlT (Reader Web.Context) () -> ActionT m ()
render content = do
    csrfToken <- lift $ fmap (.csrf) getCtx
    mUser <- lift getUser
    lastStories <- lift $ Stories.lastStories 3
    let context = Web.Context mUser csrfToken lastStories
    let page = runReader (Lucid.renderTextT (Web.root content)) context
    html page

partialRender :: (HasUser m, HasCtx m, CanDB m, MonadIO m) =>
     Lucid.HtmlT (Reader Web.Context) () -> ActionT m ()
partialRender content = do
    csrfToken <- lift $ fmap (.csrf) getCtx
    mUser <- lift getUser
    lastStories <- lift $ Stories.lastStories 3
    let context = Web.Context mUser csrfToken lastStories
    let page = runReader (Lucid.renderTextT content) context
    html page

limitHttpMethods :: Middleware
limitHttpMethods app req resp = do
    let method = parseMethod req.requestMethod
    case method of
        Left _ -> resp $ responseLBS status400 [] ""
        Right _ -> app req resp

isHtmx :: Monad m => ActionT m Bool
isHtmx = isJust <$> Scotty.header "HX-Request"

htmxRedirectMiddleware :: Middleware
htmxRedirectMiddleware app req resp = do
    let htmx = isJust $ lookup "HX-Request" $ req.requestHeaders
    app req $ \r -> do
        if htmx then do resp
            . mapResponseHeaders (map (\(k, v) -> do
                if k == "Location" then ("HX-redirect", v)
                else (k, v)
            )) $ r
        else do resp r

prepareRequest :: V.Key ReqInfo -> Connection -> LogAction IO BS.ByteString -> Middleware
prepareRequest vaultKey conn logger app req resp = do
    start <- now
    reqId <- shortId

    let method = T.decodeUtf8 req.requestMethod
    let headers = req.requestHeaders
    let cookies = T.decodeUtf8 <$> lookup "Cookie" headers
    let session = getCookieFromHeader cookies "session"

    user <- case session of
        Just s -> Users.bySession' conn s
        Nothing -> return Nothing
    let csrfCookie = getCookieFromHeader cookies "csrf_token"

    let reqInfo = ReqInfo reqId user
    let newVault = V.insert vaultKey reqInfo (vault req)
    let newReq = req { vault = newVault }

    let path = T.decodeUtf8 req.rawPathInfo
    liftIO $ logJson logger
        [ ("requestId", JSON.toJSON reqId)
        , ("path", JSON.toJSON $ method <> " " <> path)
        , ("time", JSON.toJSON start)
        , ("user", JSON.toJSON $ (.id) <$> user)
        ]
    app newReq $ \r -> do
        let status = responseStatus r
        stop <- now
        let dt :: Int = round $ 1000 * diffUTCTime stop start
        liftIO $ logJson logger
            [ ("requestId", JSON.toJSON reqId)
            , ("status", JSON.toJSON $ status.statusCode)
            , ("dt", JSON.toJSON $ show dt ++ "ms")
            , ("time", JSON.toJSON stop)
            , ("user", JSON.toJSON $ (.id) <$> user)
            ]
        resp r