{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
module Yesod.Core.Class.Yesod where
import Yesod.Core.Content
import Yesod.Core.Handler
import Yesod.Routes.Class
import Data.ByteString.Builder (Builder)
import Data.Text.Encoding (encodeUtf8Builder)
import Control.Arrow ((***), second)
import Control.Exception (bracket)
import Control.Monad (forM, when, void)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Control.Monad.Logger (LogLevel (LevelInfo, LevelOther),
LogSource, logErrorS)
import Control.Monad.Trans.Resource (InternalState, createInternalState, closeInternalState)
import qualified Data.ByteString.Char8 as S8
import qualified Data.ByteString.Lazy as L
import Data.Aeson (object, (.=))
import Data.List (foldl', nub)
import qualified Data.Map as Map
import Data.Maybe (catMaybes)
import Data.Monoid
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import Data.Text.Lazy.Builder (toLazyText)
import Data.Text.Lazy.Encoding (encodeUtf8)
import Data.Word (Word64)
import Language.Haskell.TH.Syntax (Loc (..))
import Network.HTTP.Types (encodePath)
import qualified Network.Wai as W
import Network.Wai.Parse (lbsBackEnd,
tempFileBackEnd)
import Network.Wai.Logger (ZonedDate, clockDateCacher)
import System.Log.FastLogger
import Text.Blaze (customAttribute, textTag,
toValue, (!),
preEscapedToMarkup)
import qualified Text.Blaze.Html5 as TBH
import Text.Hamlet
import Text.Julius
import qualified Web.ClientSession as CS
import Web.Cookie (SetCookie (..), parseCookies, sameSiteLax,
sameSiteStrict, SameSiteOption, defaultSetCookie)
import Yesod.Core.Types
import Yesod.Core.Internal.Session
import Yesod.Core.Widget
import Data.CaseInsensitive (CI)
import qualified Network.Wai.Request
import Data.IORef
class RenderRoute site => Yesod site where
approot :: Approot site
approot = guessApproot
errorHandler :: ErrorResponse -> HandlerFor site TypedContent
errorHandler = defaultErrorHandler
defaultLayout :: WidgetFor site () -> HandlerFor site Html
defaultLayout w = do
p <- widgetToPageContent w
msgs <- getMessages
withUrlRenderer [hamlet|
$newline never
$doctype 5
<html>
<head>
<title>#{pageTitle p}
^{pageHead p}
<body>
$forall (status, msg) <- msgs
<p class="message #{status}">#{msg}
^{pageBody p}
|]
urlParamRenderOverride :: site
-> Route site
-> [(T.Text, T.Text)]
-> Maybe Builder
urlParamRenderOverride _ _ _ = Nothing
isAuthorized :: Route site
-> Bool
-> HandlerFor site AuthResult
isAuthorized _ _ = return Authorized
isWriteRequest :: Route site -> HandlerFor site Bool
isWriteRequest _ = do
wai <- waiRequest
return $ W.requestMethod wai `notElem`
["GET", "HEAD", "OPTIONS", "TRACE"]
authRoute :: site -> Maybe (Route site)
authRoute _ = Nothing
cleanPath :: site -> [Text] -> Either [Text] [Text]
cleanPath _ s =
if corrected == s
then Right $ map dropDash s
else Left corrected
where
corrected = filter (not . T.null) s
dropDash t
| T.all (== '-') t = T.drop 1 t
| otherwise = t
joinPath :: site
-> T.Text
-> [T.Text]
-> [(T.Text, T.Text)]
-> Builder
joinPath _ ar pieces' qs' =
encodeUtf8Builder ar `mappend` encodePath pieces qs
where
pieces = if null pieces' then [""] else map addDash pieces'
qs = map (TE.encodeUtf8 *** go) qs'
go "" = Nothing
go x = Just $ TE.encodeUtf8 x
addDash t
| T.all (== '-') t = T.cons '-' t
| otherwise = t
addStaticContent :: Text
-> Text
-> L.ByteString
-> HandlerFor site (Maybe (Either Text (Route site, [(Text, Text)])))
addStaticContent _ _ _ = return Nothing
maximumContentLength :: site -> Maybe (Route site) -> Maybe Word64
maximumContentLength _ _ = Just $ 2 * 1024 * 1024
maximumContentLengthIO :: site -> Maybe (Route site) -> IO (Maybe Word64)
maximumContentLengthIO a b = pure $ maximumContentLength a b
makeLogger :: site -> IO Logger
makeLogger _ = defaultMakeLogger
messageLoggerSource :: site
-> Logger
-> Loc
-> LogSource
-> LogLevel
-> LogStr
-> IO ()
messageLoggerSource site = defaultMessageLoggerSource $ shouldLogIO site
jsLoader :: site -> ScriptLoadPosition site
jsLoader _ = BottomOfBody
jsAttributes :: site -> [(Text, Text)]
jsAttributes _ = []
jsAttributesHandler :: HandlerFor site [(Text, Text)]
jsAttributesHandler = jsAttributes <$> getYesod
makeSessionBackend :: site -> IO (Maybe SessionBackend)
makeSessionBackend _ = Just <$> defaultClientSessionBackend 120 CS.defaultKeyFile
fileUpload :: site -> W.RequestBodyLength -> FileUpload
fileUpload _ (W.KnownLength size)
| size <= 50000 = FileUploadMemory lbsBackEnd
fileUpload _ _ = FileUploadDisk tempFileBackEnd
shouldLogIO :: site -> LogSource -> LogLevel -> IO Bool
shouldLogIO _ = defaultShouldLogIO
yesodMiddleware :: ToTypedContent res => HandlerFor site res -> HandlerFor site res
yesodMiddleware = defaultYesodMiddleware
yesodWithInternalState :: site -> Maybe (Route site) -> (InternalState -> IO a) -> IO a
yesodWithInternalState _ _ = bracket createInternalState closeInternalState
{-# INLINE yesodWithInternalState #-}
defaultMessageWidget :: Html -> HtmlUrl (Route site) -> WidgetFor site ()
defaultMessageWidget title body = do
setTitle title
toWidget
[hamlet|
<h1>#{title}
^{body}
|]
defaultMakeLogger :: IO Logger
defaultMakeLogger = do
loggerSet' <- newStdoutLoggerSet defaultBufSize
(getter, _) <- clockDateCacher
return $! Logger loggerSet' getter
defaultMessageLoggerSource ::
(LogSource -> LogLevel -> IO Bool)
-> Logger
-> Loc
-> LogSource
-> LogLevel
-> LogStr
-> IO ()
defaultMessageLoggerSource ckLoggable logger loc source level msg = do
loggable <- ckLoggable source level
when loggable $
formatLogMessage (loggerDate logger) loc source level msg >>=
loggerPutStr logger
defaultShouldLogIO :: LogSource -> LogLevel -> IO Bool
defaultShouldLogIO _ level = return $ level >= LevelInfo
defaultYesodMiddleware :: Yesod site => HandlerFor site res -> HandlerFor site res
defaultYesodMiddleware handler = do
addHeader "Vary" "Accept, Accept-Language"
addHeader "X-XSS-Protection" "1; mode=block"
authorizationCheck
handler
sslOnlySessions :: IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
sslOnlySessions = (fmap . fmap) secureSessionCookies
where
setSecureBit cookie = cookie { setCookieSecure = True }
secureSessionCookies = customizeSessionCookies setSecureBit
laxSameSiteSessions :: IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
laxSameSiteSessions = sameSiteSession sameSiteLax
strictSameSiteSessions :: IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
strictSameSiteSessions = sameSiteSession sameSiteStrict
sameSiteSession :: SameSiteOption -> IO (Maybe SessionBackend) -> IO (Maybe SessionBackend)
sameSiteSession s = (fmap . fmap) secureSessionCookies
where
sameSite cookie = cookie { setCookieSameSite = Just s }
secureSessionCookies = customizeSessionCookies sameSite
sslOnlyMiddleware :: Int
-> HandlerFor site res
-> HandlerFor site res
sslOnlyMiddleware timeout handler = do
addHeader "Strict-Transport-Security"
$ T.pack $ concat [ "max-age="
, show $ timeout * 60
, "; includeSubDomains"
]
handler
authorizationCheck :: Yesod site => HandlerFor site ()
authorizationCheck = getCurrentRoute >>= maybe (return ()) checkUrl
where
checkUrl url = do
isWrite <- isWriteRequest url
ar <- isAuthorized url isWrite
case ar of
Authorized -> return ()
AuthenticationRequired -> do
master <- getYesod
case authRoute master of
Nothing -> void notAuthenticated
Just url' ->
void $ selectRep $ do
provideRepType typeHtml $ do
setUltDestCurrent
void $ redirect url'
provideRepType typeJson $
void notAuthenticated
Unauthorized s' -> permissionDenied s'
defaultCsrfCheckMiddleware :: Yesod site => HandlerFor site res -> HandlerFor site res
defaultCsrfCheckMiddleware handler =
csrfCheckMiddleware
handler
(getCurrentRoute >>= maybe (return False) isWriteRequest)
defaultCsrfHeaderName
defaultCsrfParamName
csrfCheckMiddleware :: HandlerFor site res
-> HandlerFor site Bool
-> CI S8.ByteString
-> Text
-> HandlerFor site res
csrfCheckMiddleware handler shouldCheckFn headerName paramName = do
shouldCheck <- shouldCheckFn
when shouldCheck (checkCsrfHeaderOrParam headerName paramName)
handler
defaultCsrfSetCookieMiddleware :: HandlerFor site res -> HandlerFor site res
defaultCsrfSetCookieMiddleware handler = setCsrfCookie >> handler
csrfSetCookieMiddleware :: HandlerFor site res -> SetCookie -> HandlerFor site res
csrfSetCookieMiddleware handler cookie = setCsrfCookieWithCookie cookie >> handler
defaultCsrfMiddleware :: Yesod site => HandlerFor site res -> HandlerFor site res
defaultCsrfMiddleware = defaultCsrfSetCookieMiddleware . defaultCsrfCheckMiddleware
widgetToPageContent :: Yesod site
=> WidgetFor site ()
-> HandlerFor site (PageContent (Route site))
widgetToPageContent w = do
jsAttrs <- jsAttributesHandler
HandlerFor $ \hd -> do
master <- unHandlerFor getYesod hd
ref <- newIORef mempty
unWidgetFor w WidgetData
{ wdRef = ref
, wdHandler = hd
}
GWData (Body body) (Last mTitle) scripts' stylesheets' style jscript (Head head') <- readIORef ref
let title = maybe mempty unTitle mTitle
scripts = runUniqueList scripts'
stylesheets = runUniqueList stylesheets'
flip unHandlerFor hd $ do
render <- getUrlRenderParams
let renderLoc x =
case x of
Nothing -> Nothing
Just (Left s) -> Just s
Just (Right (u, p)) -> Just $ render u p
css <- forM (Map.toList style) $ \(mmedia, content) -> do
let rendered = toLazyText $ content render
x <- addStaticContent "css" "text/css; charset=utf-8"
$ encodeUtf8 rendered
return (mmedia,
case x of
Nothing -> Left $ preEscapedToMarkup rendered
Just y -> Right $ either id (uncurry render) y)
jsLoc <-
case jscript of
Nothing -> return Nothing
Just s -> do
x <- addStaticContent "js" "text/javascript; charset=utf-8"
$ encodeUtf8 $ renderJavascriptUrl render s
return $ renderLoc x
let (mcomplete, asyncScripts) = asyncHelper render scripts jscript jsLoc
regularScriptLoad = [hamlet|
$newline never
$forall s <- scripts
^{mkScriptTag s}
$maybe j <- jscript
$maybe s <- jsLoc
<script src="#{s}" *{jsAttrs}>
$nothing
<script>^{jelper j}
|]
headAll = [hamlet|
$newline never
\^{head'}
$forall s <- stylesheets
^{mkLinkTag s}
$forall s <- css
$maybe t <- right $ snd s
$maybe media <- fst s
<link rel=stylesheet media=#{media} href=#{t}>
$nothing
<link rel=stylesheet href=#{t}>
$maybe content <- left $ snd s
$maybe media <- fst s
<style media=#{media}>#{content}
$nothing
<style>#{content}
$case jsLoader master
$of BottomOfBody
$of BottomOfHeadAsync asyncJsLoader
^{asyncJsLoader asyncScripts mcomplete}
$of BottomOfHeadBlocking
^{regularScriptLoad}
|]
let bodyScript = [hamlet|
$newline never
^{body}
^{regularScriptLoad}
|]
return $ PageContent title headAll $
case jsLoader master of
BottomOfBody -> bodyScript
_ -> body
where
renderLoc' render' (Local url) = render' url []
renderLoc' _ (Remote s) = s
addAttr x (y, z) = x ! customAttribute (textTag y) (toValue z)
mkScriptTag (Script loc attrs) render' =
foldl' addAttr TBH.script (("src", renderLoc' render' loc) : attrs) $ return ()
mkLinkTag (Stylesheet loc attrs) render' =
foldl' addAttr TBH.link
( ("rel", "stylesheet")
: ("href", renderLoc' render' loc)
: attrs
)
runUniqueList :: Eq x => UniqueList x -> [x]
runUniqueList (UniqueList x) = nub $ x []
defaultErrorHandler :: Yesod site => ErrorResponse -> HandlerFor site TypedContent
defaultErrorHandler NotFound = selectRep $ do
provideRep $ defaultLayout $ do
r <- waiRequest
let path' = TE.decodeUtf8With TEE.lenientDecode $ W.rawPathInfo r
defaultMessageWidget "Not Found" [hamlet|<p>#{path'}|]
provideRep $ return $ object ["message" .= ("Not Found" :: Text)]
provideRep $ return ("Not Found" :: Text)
defaultErrorHandler NotAuthenticated = selectRep $ do
provideRep $ defaultLayout $ defaultMessageWidget
"Not logged in"
[hamlet|<p style="display:none;">Set the authRoute and the user will be redirected there.|]
provideRep $ do
addHeader "WWW-Authenticate" "RedirectJSON realm=\"application\", param=\"authentication_url\""
site <- getYesod
rend <- getUrlRender
let apair u = ["authentication_url" .= rend u]
content = maybe [] apair (authRoute site)
return $ object $ ("message" .= ("Not logged in"::Text)):content
provideRep $ return ("Not logged in" :: Text)
defaultErrorHandler (PermissionDenied msg) = selectRep $ do
provideRep $ defaultLayout $ defaultMessageWidget
"Permission Denied"
[hamlet|<p>#{msg}|]
provideRep $
return $ object ["message" .= ("Permission Denied. " <> msg)]
provideRep $ return $ "Permission Denied. " <> msg
defaultErrorHandler (InvalidArgs ia) = selectRep $ do
provideRep $ defaultLayout $ defaultMessageWidget
"Invalid Arguments"
[hamlet|
<ul>
$forall msg <- ia
<li>#{msg}
|]
provideRep $ return $ object ["message" .= ("Invalid Arguments" :: Text), "errors" .= ia]
provideRep $ return ("Invalid Arguments: " <> T.intercalate " " ia)
defaultErrorHandler (InternalError e) = do
$logErrorS "yesod-core" e
selectRep $ do
provideRep $ defaultLayout $ defaultMessageWidget
"Internal Server Error"
[hamlet|<pre>#{e}|]
provideRep $ return $ object ["message" .= ("Internal Server Error" :: Text), "error" .= e]
provideRep $ return $ "Internal Server Error: " <> e
defaultErrorHandler (BadMethod m) = selectRep $ do
provideRep $ defaultLayout $ defaultMessageWidget
"Method Not Supported"
[hamlet|<p>Method <code>#{S8.unpack m}</code> not supported|]
provideRep $ return $ object ["message" .= ("Bad method" :: Text), "method" .= TE.decodeUtf8With TEE.lenientDecode m]
provideRep $ return $ "Bad Method " <> TE.decodeUtf8With TEE.lenientDecode m
asyncHelper :: (url -> [x] -> Text)
-> [Script url]
-> Maybe (JavascriptUrl url)
-> Maybe Text
-> (Maybe (HtmlUrl url), [Text])
asyncHelper render scripts jscript jsLoc =
(mcomplete, scripts'')
where
scripts' = map goScript scripts
scripts'' =
case jsLoc of
Just s -> scripts' ++ [s]
Nothing -> scripts'
goScript (Script (Local url) _) = render url []
goScript (Script (Remote s) _) = s
mcomplete =
case jsLoc of
Just{} -> Nothing
Nothing ->
case jscript of
Nothing -> Nothing
Just j -> Just $ jelper j
formatLogMessage :: IO ZonedDate
-> Loc
-> LogSource
-> LogLevel
-> LogStr
-> IO LogStr
formatLogMessage getdate loc src level msg = do
now <- getdate
return $ mempty
`mappend` toLogStr now
`mappend` " ["
`mappend` (case level of
LevelOther t -> toLogStr t
_ -> toLogStr $ drop 5 $ show level)
`mappend` (if T.null src
then mempty
else "#" `mappend` toLogStr src)
`mappend` "] "
`mappend` msg
`mappend` sourceSuffix
`mappend` "\n"
where
sourceSuffix = if loc_package loc == "<unknown>" then "" else mempty
`mappend` " @("
`mappend` toLogStr (fileLocationToString loc)
`mappend` ")"
customizeSessionCookies :: (SetCookie -> SetCookie) -> (SessionBackend -> SessionBackend)
customizeSessionCookies customizeCookie backend = backend'
where
customizeHeader (AddCookie cookie) = AddCookie (customizeCookie cookie)
customizeHeader other = other
customizeSaveSession = (fmap . fmap . fmap) customizeHeader
backend' =
backend {
sbLoadSession = \req ->
second customizeSaveSession `fmap` sbLoadSession backend req
}
defaultClientSessionBackend :: Int
-> FilePath
-> IO SessionBackend
defaultClientSessionBackend minutes fp = do
key <- CS.getKey fp
(getCachedDate, _closeDateCacher) <- clientSessionDateCacher (minToSec minutes)
return $ clientSessionBackend key getCachedDate
envClientSessionBackend :: Int
-> String
-> IO SessionBackend
envClientSessionBackend minutes name = do
key <- CS.getKeyEnv name
(getCachedDate, _closeDateCacher) <- clientSessionDateCacher $ minToSec minutes
return $ clientSessionBackend key getCachedDate
minToSec :: (Integral a, Num b) => a -> b
minToSec minutes = fromIntegral (minutes * 60)
jsToHtml :: Javascript -> Html
jsToHtml (Javascript b) = preEscapedToMarkup $ toLazyText b
jelper :: JavascriptUrl url -> HtmlUrl url
jelper = fmap jsToHtml
left :: Either a b -> Maybe a
left (Left x) = Just x
left _ = Nothing
right :: Either a b -> Maybe b
right (Right x) = Just x
right _ = Nothing
clientSessionBackend :: CS.Key
-> IO ClientSessionDateCache
-> SessionBackend
clientSessionBackend key getCachedDate =
SessionBackend {
sbLoadSession = loadClientSession key getCachedDate "_SESSION"
}
justSingleton :: a -> [Maybe a] -> a
justSingleton d = just . catMaybes
where
just [s] = s
just _ = d
loadClientSession :: CS.Key
-> IO ClientSessionDateCache
-> S8.ByteString
-> W.Request
-> IO (SessionMap, SaveSession)
loadClientSession key getCachedDate sessionName req = load
where
load = do
date <- getCachedDate
return (sess date, save date)
sess date = justSingleton Map.empty $ do
raw <- [v | (k, v) <- W.requestHeaders req, k == "Cookie"]
val <- [v | (k, v) <- parseCookies raw, k == sessionName]
let host = ""
return $ decodeClientSession key date host val
save date sess' = do
iv <- liftIO CS.randomIV
return [AddCookie defaultSetCookie
{ setCookieName = sessionName
, setCookieValue = encodeClientSession key iv date host sess'
, setCookiePath = Just "/"
, setCookieExpires = Just (csdcExpires date)
, setCookieDomain = Nothing
, setCookieHttpOnly = True
}]
where
host = ""
fileLocationToString :: Loc -> String
fileLocationToString loc =
concat
[ loc_package loc
, ':' : loc_module loc
, ' ' : loc_filename loc
, ':' : line loc
, ':' : char loc
]
where
line = show . fst . loc_start
char = show . snd . loc_start
guessApproot :: Approot site
guessApproot = guessApprootOr ApprootRelative
guessApprootOr :: Approot site -> Approot site
guessApprootOr fallback = ApprootRequest $ \master req ->
case W.requestHeaderHost req of
Nothing -> getApprootText fallback master req
Just host ->
(if Network.Wai.Request.appearsSecure req
then "https://"
else "http://")
`T.append` TE.decodeUtf8With TEE.lenientDecode host
getApprootText :: Approot site -> site -> W.Request -> Text
getApprootText ar site req =
case ar of
ApprootRelative -> ""
ApprootStatic t -> t
ApprootMaster f -> f site
ApprootRequest f -> f site req