a-piece-of-flake-0.0.1: src/PieceOfFlake/Yesod.hs
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeFamilies #-}
module PieceOfFlake.Yesod where
import Data.Binary.Builder (fromByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as B8
import Data.Map.Strict qualified as M
import Data.Text (intercalate)
import GHC.Records ( HasField )
import Network.HTTP.Types qualified as H
import Network.HTTP.Types.Header ( hAcceptEncoding )
import Network.Socket (SockAddr(SockAddrInet), hostAddressToTuple, tupleToHostAddress, HostAddress)
import Network.Wai ( Request(remoteHost) )
import PieceOfFlake.CmdArgs ( StaticCacheSeconds )
import PieceOfFlake.Prelude
import Text.Blaze ( ToMarkup(toMarkup) )
import Text.Show qualified as TS
import Text.Regex.TDFA ( AllTextSubmatches(getAllTextSubmatches), (=~) )
import Yesod.Core
( hamlet,
getYesod,
cacheSeconds,
widgetToPageContent,
typeSvg,
getMessages,
sendResponseStatus,
waiRequest,
withUrlRenderer,
Html,
Yesod,
ToContent(..),
ToTypedContent(..),
Content(ContentBuilder),
HandlerFor,
PageContent(pageBody, pageTitle, pageDescription, pageHead),
TypedContent(TypedContent),
WidgetFor, lookupHeader, addHeader, typeJson )
newtype HostIp = HostIp HostAddress deriving newtype (Eq, Ord)
instance Show HostIp where
show = toString . hostIpToDec
instance IsString HostIp where
fromString s =
fromMaybe (error . toText $ "Failed to parse [" <> s <> "] as IPv4 address") $ parseIpV4 s
newtype ClientAdr = ClientAdr HostIp deriving (Eq, Ord)
instance Show ClientAdr where
show = show . clientAdrToDec4
clientAdrToDec4 :: ClientAdr -> Text
clientAdrToDec4 (ClientAdr sa) = hostIpToDec sa
getClientAdr :: HandlerFor a ClientAdr
getClientAdr =
waiRequest >>= \r ->
case remoteHost r of
SockAddrInet _port hip -> pure . ClientAdr $ HostIp hip
_ -> throwIO $ stringException "Unsupported socket addr"
parseIpV4 :: String -> Maybe HostIp
parseIpV4 s =
case getAllTextSubmatches (s =~ ipPat) of
[_full, a, b, c, d] ->
case readEither a of
Left _ -> Nothing
Right ai ->
case readEither b of
Left _ -> Nothing
Right bi ->
case readEither c of
Left _ -> Nothing
Right ci ->
case readEither d of
Left _ -> Nothing
Right di ->
pure . HostIp $ tupleToHostAddress (ai, bi, ci, di)
_ -> Nothing
where
ipPat :: String = "([[:digit:]]+)[.]([[:digit:]]+)[.]([[:digit:]]+)[.]([[:digit:]]+)"
hostIpToDec :: HostIp -> Text
hostIpToDec (HostIp hip) =
case hostAddressToTuple hip of
(a, b, c, d) -> intercalate "." $ fmap show [a, b, c, d]
internalError :: Text -> HandlerFor y a
internalError =
sendResponseStatus H.status503
data Contentable = forall x. (ToContent x, ToTypedContent x) => Contentable x
instance ToContent Contentable where
toContent (Contentable x) = toContent x
instance ToTypedContent Contentable where
toTypedContent (Contentable x) = toTypedContent x
newtype FavIcon = FavIcon ByteString
instance ToContent FavIcon where
toContent (FavIcon bs) =
ContentBuilder (fromByteString bs) (Just . fromIntegral $ BS.length bs)
instance ToTypedContent FavIcon where
toTypedContent = TypedContent typeSvg . toContent
mp3Mime :: Mime
mp3Mime = Mime "audio/mpeg"
bulmaLayout :: Yesod site => WidgetFor site () -> HandlerFor site Html
bulmaLayout w = do
p <- widgetToPageContent w
msgs <- getMessages
withUrlRenderer
[hamlet|
$newline never
$doctype 5
<html>
<head>
<title>#{pageTitle p}
$maybe description <- pageDescription p
<meta name="description" content="#{description}">
^{pageHead p}
<body>
$forall (status, msg) <- msgs
<p class="message #{status}">#{msg}
^{pageBody p}
|]
newtype Ts = Ts { unTs :: UtcBox } deriving newtype (Show, Eq, Ord)
instance ToMarkup Ts where
toMarkup = toMarkup . show @Text . unTs
type HasCacheField y = (HasField "staticCache" y (Tagged StaticCacheSeconds Word32), Yesod y)
setCacheHeaderForStatic :: HasCacheField y => HandlerFor y ()
setCacheHeaderForStatic = do
y <- getYesod
cacheSeconds . fromIntegral $ untag y.staticCache
newtype Mime = Mime ByteString
sendStaticBs :: (HasCacheField y, ToContent a) => Mime -> a -> HandlerFor y TypedContent
sendStaticBs (Mime mime) c = do
setCacheHeaderForStatic
pure . TypedContent mime $ toContent c
data ContentEncoding = Gzip | Br deriving (Show, Eq, Ord)
contentEncodingToHeaderValue :: ContentEncoding -> Text
contentEncodingToHeaderValue = \case
Gzip -> "gzip"
Br -> "br"
-- to customize Accpet-Encoding in Firefox:
-- open tab about:config -> network.http.accept-encoding.secure
parseContentEncoding :: Monad m => ByteString -> (Maybe ContentEncoding -> m a) -> m a
parseContentEncoding ce cb = go Nothing "" ce
where
go bestMatch en bs =
case B8.uncons bs of
Nothing ->
case en of
"gzip" -> cb $ bestMatch <|> pure Gzip
"br" -> cb $ pure Br
_ -> cb bestMatch
Just (',', bs') ->
case en of
"gzip" -> go (Just Gzip) "" (B8.drop 1 bs')
"br" -> cb $ pure Br
_ -> go bestMatch "" (B8.drop 1 bs')
Just (c, bs') ->
go bestMatch (en `B8.snoc` c) bs'
staticFile :: (ToContent a, HasCacheField y) => Mime -> a -> Map ContentEncoding a -> HandlerFor y TypedContent
staticFile mime plainContent preEncodedContent = do
lookupHeader hAcceptEncoding >>= \case
Nothing -> sendStaticBs mime plainContent
Just ae -> parseContentEncoding ae $ \case
Nothing -> sendStaticBs mime plainContent
Just ce ->
case M.lookup ce preEncodedContent of
Nothing -> sendStaticBs mime plainContent
Just cnt -> do
addHeader "Content-Encoding" $ contentEncodingToHeaderValue ce
sendStaticBs mime cnt
-- addHeader :: MonadHandler m => Text -> Text -> m ()
-- replaceOrAddHeader :: MonadHandler m => Text -> Text -> m ()
-- lookupHeader :: MonadHandler m => CI ByteString -> m (Maybe ByteString)
-- get supported encodings
-- pick best
-- set Content-Encoding header
-- | Handler () - is encoded as an empty string even with header Accepted equals to "application/json"
-- meanwhile Aeson.encode () = "[]" and therefore Aeson decode expect the same
data Unit = Unit deriving (Show, Eq)
instance ToContent Unit where
toContent _ = toContent ("[]" :: ByteString)
instance ToTypedContent Unit where
toTypedContent = TypedContent typeJson . toContent