espial-0.0.37: src/Foundation.hs
{-# OPTIONS_GHC -fno-warn-unused-matches #-}
module Foundation where
import Archiver.Backend (ArchiverBackend)
import ClassyPrelude.Yesod qualified as CP (Lang)
import Data.CaseInsensitive qualified as CI
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error qualified as TEE
import Data.Type.Equality (type (~))
import Database.Persist.Sql (ConnectionPool, runSqlPool)
import Import.NoFoundation
import Network.Wai qualified as Wai
import PathPiece ()
import Text.Hamlet (hamletFile)
import Yesod.Auth.Message
import Yesod.Core.Types
import Yesod.Core.Unsafe qualified as Unsafe
-- * App
type PublicTagCloudCache = IORef (Map (Text, TagCloudMode) (UTCTime, Map Text Int))
data App = App
{ appSettings :: AppSettings,
-- | Settings for static file serving.
appStatic :: Static,
-- | Frontend ESM bundle filename under static/js.
appFrontendBundleName :: Text,
-- | Database connection pool.
appConnPool :: ConnectionPool,
-- | HTTP manager, used for making HTTP requests to external services.
appHttpManager :: Manager,
-- | Logger
appLogger :: Logger,
-- | Active archiver plugin; 'Nothing' disables archiving.
appArchiver :: Maybe ArchiverBackend,
-- | i18n translation function
appTranslate :: I18nLang -> I18nKey -> Text,
-- | Route to the i18n json data
appI18nR :: Route App,
-- | In-memory cache for public tag cloud responses (30s TTL, 1000-entry cap).
appPublicTagCloudCache :: PublicTagCloudCache
}
deriving (Typeable)
mkYesodData "App" $(parseRoutesFile "config/routes")
deriving instance Typeable Route
deriving instance Generic (Route App)
-- YesodPersist
instance YesodPersist App where
type YesodPersistBackend App = SqlBackend
runDB action = do
app <- getYesod
runSqlPool action (appConnPool app)
instance YesodPersistRunner App where
getDBRunner = defaultGetDBRunner appConnPool
session_timeout_minutes :: Int
session_timeout_minutes = 10080 -- (7 days)
-- * Yesod App
instance Yesod App where
approot = ApprootRequest \app req ->
case appRoot (appSettings app) of
Nothing -> getApprootText guessApproot app req
Just root -> root
makeSessionBackend :: App -> IO (Maybe SessionBackend)
makeSessionBackend App {appSettings} = do
backend <-
defaultClientSessionBackend
session_timeout_minutes
"config/client_session_key.aes"
maybeSSLOnly $ pure (Just backend)
where
maybeSSLOnly =
if appSSLOnly appSettings
then sslOnlySessions
else id
yesodMiddleware :: HandlerFor App res -> HandlerFor App res
yesodMiddleware = customMiddleware . defaultYesodMiddleware . customCsrfMiddleware
where
customCsrfMiddleware handler = do
maybeRoute <- getCurrentRoute
dontCheckCsrf <- case maybeRoute of
-- `maybeAuthId` checks for the validity of the Authorization
-- header anyway, but it is still a good idea to limit this
-- flexibility to designated routes.
-- For the time being, `AddR` is the only route that accepts an
-- authentication token.
Just AddR -> isJust <$> lookupHeader "Authorization"
_ -> pure False
(if dontCheckCsrf then id else defaultCsrfMiddleware) handler
customMiddleware handler = do
addHeader "X-Frame-Options" "DENY"
yesod <- getYesod
( if appSSLOnly (appSettings yesod)
then sslOnlyMiddleware session_timeout_minutes
else id
)
handler
jsAttributes _ = [("type", "module")]
shouldLogIO app "yesod-core" LevelWarn = pure False -- remove verbose CSRF token warnings
shouldLogIO app "startup" level =
pure
( ( appEnableStartupLogging (appSettings app)
&& (appShouldLogAll (appSettings app) || level == LevelInfo || level == LevelWarn || level == LevelError)
)
|| level == LevelWarn
|| level == LevelError
)
shouldLogIO app source level = pure $ appShouldLogAll (appSettings app) || level == LevelWarn || level == LevelError
makeLogger = return . appLogger
authRoute _ = Just (AuthR LoginR)
isAuthorized (AuthR _) _ = pure Authorized
isAuthorized _ _ = pure Authorized
errorHandler :: ErrorResponse -> HandlerFor App TypedContent
errorHandler err = do
app <- getYesod
session <- getSession
let lang = maybe (appLanguageDefault (appSettings app)) id (toI18nLang . decodeUtf8 =<< lookup "_LANG" session)
t = \key -> appTranslate app lang (I18nKey key)
case err of
NotFound -> _notFoundErrorHandler (t "error.notFound")
InternalError e -> _internalErrorHandler e (t "error.internalError")
NotAuthenticated -> _notAuthenticatedErrorHandler (t "error.notAuthenticated")
BadMethod _ -> _genericErrorHandler (t "error.badMethod")
InvalidArgs _ -> _genericErrorHandler (t "error.invalidArgs")
PermissionDenied _ -> _genericErrorHandler (t "error.permissionDenied")
where
_notFoundErrorHandler :: Text -> HandlerFor App TypedContent
_notFoundErrorHandler title = do
selectRep $ do
provideRep $ defaultLayout $ do
r <- waiRequest
let path' = TE.decodeUtf8With TEE.lenientDecode $ Wai.rawPathInfo r
defaultMessageWidget (fromString (unpack (title))) [hamlet|<p>#{path'}|]
provideRep $ return $ object ["message" .= title]
provideRep $ return title
_internalErrorHandler :: Text -> Text -> HandlerFor App TypedContent
_internalErrorHandler e title = do
$logErrorS "yesod-core" e
selectRep $ do
provideRep $ defaultLayout $ do
defaultMessageWidget (fromString (unpack (title))) [hamlet|<pre>#{e}|]
provideRep $ return $ object ["message" .= title, "error" .= e]
provideRep $ return $ title <> ": " <> e
_notAuthenticatedErrorHandler :: Text -> HandlerFor App TypedContent
_notAuthenticatedErrorHandler title = selectRep $ do
provideRep $
defaultLayout $
defaultMessageWidget (fromString (unpack (title))) [hamlet|<p style="display:none;"><p>#{title} |]
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" .= title) : content
provideRep $ return title
_genericErrorHandler :: Text -> HandlerFor App TypedContent
_genericErrorHandler title = do
selectRep $ do
provideRep $ defaultLayout $ do
defaultMessageWidget (fromString (unpack (title))) [hamlet|<p>#{title}|]
provideRep $ return $ object ["message" .= title]
provideRep $ return $ title
defaultMessageWidget :: Html -> HtmlUrl (Route App) -> WidgetFor App ()
defaultMessageWidget title body = do
setTitle title
toWidget
[hamlet|
<main .pv2.ph3.mh1>
<div .w-100.mw8.center>
<div .pa3.thm-bg-secondary>
<h1>#{title}
^{body}
|]
defaultLayout :: WidgetFor App () -> HandlerFor App Html
defaultLayout widget = do
req <- getRequest
app <- getYesod
urlrender <- getUrlRender
mmsg <- getMessage
musername <- maybeAuthUsername
muser <- (fmap . fmap) snd maybeAuthPair
session <- getSession
let msourceCodeUri = appSourceCodeUri (appSettings app)
frontendBundleName = appFrontendBundleName app
lang = fromMaybe (appLanguageDefault (appSettings app)) ((muser >>= userLanguage) <|> (toI18nLang . decodeUtf8 =<< lookup "_LANG" session))
t = \key -> appTranslate app lang (I18nKey key)
i18nR = appI18nR app
pc <- widgetToPageContent do
setTitle "Espial"
addStylesheet (StaticR css_tachyons_min_css)
addStylesheet (StaticR css_common_css)
addStylesheet (StaticR css_main_css)
$(widgetFile "default-layout")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
popupLayout :: Widget -> Handler Html
popupLayout widget = do
req <- getRequest
app <- getYesod
mmsg <- getMessage
session <- getSession
muser <- fmap entityVal <$> maybeAuth
let musername = fmap userName muser
msourceCodeUri = appSourceCodeUri (appSettings app)
frontendBundleName = appFrontendBundleName app
lang = fromMaybe (appLanguageDefault (appSettings app)) ((muser >>= userLanguage) <|> (toI18nLang . decodeUtf8 =<< lookup "_LANG" session))
t = \key -> appTranslate app lang (I18nKey key)
i18nR = appI18nR app
pc <- widgetToPageContent do
setTitle "Espial"
addStylesheet (StaticR css_tachyons_min_css)
addStylesheet (StaticR css_common_css)
addStylesheet (StaticR css_popup_css)
$(widgetFile "popup-layout")
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
-- * YesodAuth App
instance YesodAuth App where
type AuthId App = UserId
authPlugins _ = [dbAuthPlugin]
where
dbAuthPlugin :: AuthPlugin App
dbAuthPlugin = AuthPlugin dbAuthPluginName dbDispatch dbLoginHandler
where
dbDispatch :: Text -> [Text] -> AuthHandler App TypedContent
dbDispatch "POST" ["login"] = dbPostLoginR >>= sendResponse
dbDispatch _ _ = notFound
dbLoginHandler toParent = do
req <- getRequest
lookupSession ultDestKey >>= \case
Just dest | "logout" `isInfixOf` dest -> deleteSession ultDestKey
_ -> pure ()
app <- getYesod
session <- getSession
let lang = maybe (appLanguageDefault (appSettings app)) id (toI18nLang . decodeUtf8 =<< lookup "_LANG" session)
t = \key -> appTranslate app lang (I18nKey key)
setTitle ("Espial | " <> fromString (unpack (t "login.pageTitle")))
$(widgetFile "login")
dbPostLoginR :: (master ~ App) => AuthHandler master TypedContent
dbPostLoginR = do
mresult <-
runInputPostResult
( dbLoginCreds
<$> ireq textField "username"
<*> ireq textField "password"
)
case mresult of
FormSuccess creds -> setCredsRedirect creds
_ -> do
app <- getYesod
session <- getSession
let lang = maybe (appLanguageDefault (appSettings app)) id (toI18nLang . decodeUtf8 =<< lookup "_LANG" session)
t = \key -> appTranslate app lang (I18nKey key)
loginErrorMessage (AuthR LoginR) (t "auth.invalidUsernamePass")
where
dbLoginCreds :: Text -> Text -> Creds master
dbLoginCreds username password =
Creds
{ credsPlugin = dbAuthPluginName,
credsIdent = username,
credsExtra = [("password", password)]
}
ultDestKey :: Text
ultDestKey = "_ULT"
authenticate = authenticateCreds
where
authenticateCreds ::
(MonadHandler m, HandlerSite m ~ App) =>
Creds App ->
m (AuthenticationResult App)
authenticateCreds Creds {..} = do
muser <- case (credsPlugin, lookup "password" credsExtra) of
(plugin, Just pwd)
| plugin == dbAuthPluginName ->
liftHandler $ runDB $ authenticatePassword credsIdent pwd
_ -> pure Nothing
case muser of
Nothing -> pure (UserError InvalidUsernamePass)
Just (Entity uid _) -> pure (Authenticated uid)
loginDest = const HomeR
logoutDest = const HomeR
onLogin =
maybeAuth >>= \case
Nothing -> cpprint ("onLogin: could not find user" :: Text)
Just (Entity user uname) -> do
app <- getYesod
let lang = fromMaybe (appLanguageDefault (appSettings app)) (userLanguage uname)
setSession userNameKey (userName uname)
setLanguage (fromI18nLang lang)
onLogout =
deleteSession userNameKey
redirectToReferer = const True
maybeAuthId = do
req <- waiRequest
let mAuthHeader = lookup "Authorization" (Wai.requestHeaders req)
extractKey = stripPrefix "ApiKey " . TE.decodeUtf8
case mAuthHeader of
Just authHeader ->
case extractKey authHeader of
Just apiKey -> do
user <- liftHandler $ runDB $ getApiKeyUser (ApiKey apiKey)
let userId = entityKey <$> user
pure userId
-- Since we disable CSRF middleware in the presence of Authorization
-- header, we need to explicitly check for the validity of the header
-- content. Otherwise, a dummy Authorization header with garbage input
-- could be provided to circumvent CSRF token requirement, making the app
-- vulnerable to CSRF attacks.
Nothing -> pure Nothing
_ -> defaultMaybeAuthId
renderAuthMessage :: App -> [Text] -> AuthMessage -> Text
renderAuthMessage app langs msg = do
let settings = appSettings app
pickLang (langs ++ [CI.foldedCase $ fromI18nLang (appLanguageDefault settings)])
where
pickLang [] = englishMessage msg
pickLang (l : ls) = case l of
"en" -> englishMessage msg
"cs" -> czechMessage msg
"da" -> danishMessage msg
"de" -> germanMessage msg
"es" -> spanishMessage msg
"fi" -> finnishMessage msg
"fr" -> frenchMessage msg
"hr" -> croatianMessage msg
"ja" -> japaneseMessage msg
"ko" -> koreanMessage msg
"nb" -> norwegianBokmålMessage msg
"nl" -> dutchMessage msg
"pt" -> portugueseMessage msg
"pl" -> pickLang ("cs" : ls) -- TODO
"ro" -> romanianMessage msg
"ru" -> russianMessage msg
"sv" -> swedishMessage msg
"zh" -> chineseMessage msg
_ -> pickLang ls
instance YesodAuthPersist App
-- instance RenderMessage App AuthMessage where
-- renderMessage :: App -> [CP.Lang] -> AuthMessage -> Text
-- renderMessage _ _ msg = defaultMessage msg
instance RenderMessage App FormMessage where
renderMessage :: App -> [CP.Lang] -> FormMessage -> Text
renderMessage _ _ = defaultFormMessage
instance HasHttpManager App where
getHttpManager :: App -> Manager
getHttpManager = appHttpManager
-- session keys
maybeAuthUsername :: Handler (Maybe Text)
maybeAuthUsername = do
lookupSession userNameKey
userNameKey :: Text
userNameKey = "_UNAME"
-- dbAuthPlugin
dbAuthPluginName :: Text
dbAuthPluginName = "db"
dbLoginR :: AuthRoute
dbLoginR = PluginR dbAuthPluginName ["login"]
-- Util
unsafeHandler :: App -> Handler a -> IO a
unsafeHandler = Unsafe.fakeHandlerGetLogger appLogger