Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Base64 get/setCookie and documentation to Obelisk.Frontend.Cookie #1017

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion lib/backend/src/Obelisk/Backend.hs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ import Obelisk.Snap.Extras (doNotCache, serveFileIfExistsAs)
import Reflex.Dom.Core
import Snap (MonadSnap, Snap, commandLineConfig, defaultConfig, getsRequest, httpServe, modifyResponse
, rqPathInfo, rqQueryString, setContentType, writeBS, writeText
, rqCookies, Cookie(..) , setHeader)
, rqCookies, Cookie(..) , setHeader, addResponseCookie)
import Snap.Internal.Http.Server.Config (Config (accessLog, errorLog), ConfigLog (ConfigIoLog))
import System.IO (BufferMode (..), hSetBuffering, stderr, stdout)
import Web.Cookie
( SetCookie(setCookieHttpOnly, setCookieSecure, setCookiePath,
setCookieDomain, setCookieExpires, setCookieValue, setCookieName) )

data Backend backendRoute frontendRoute = Backend
{ _backend_routeEncoder :: Encoder (Either Text) Identity (R (FullRoute backendRoute frontendRoute)) PageName
Expand Down Expand Up @@ -296,6 +299,19 @@ delayedGhcjsScript n allJsUrl = elAttr "script" ("type" =: "text/javascript") $
instance HasCookies Snap where
askCookies = map (\c -> (cookieName c, cookieValue c)) <$> getsRequest rqCookies

instance HasSetCookie Snap where
setCookieRaw ck = do
let snapCookie = Cookie
{ cookieName = setCookieName ck,
cookieValue = setCookieValue ck,
cookieExpires = setCookieExpires ck,
cookieDomain = setCookieDomain ck,
cookiePath = setCookiePath ck,
cookieSecure = setCookieSecure ck,
cookieHttpOnly = setCookieHttpOnly ck
}
modifyResponse (addResponseCookie snapCookie)

-- | Get configs from the canonical "public" locations (i.e., locations that obelisk expects to make available
-- to frontend applications, and hence visible to end users).
getPublicConfigs :: IO (Map Text ByteString)
Expand Down
5 changes: 4 additions & 1 deletion lib/frontend/obelisk-frontend.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ library
reflex,
reflex-dom-core,
text,
transformers
transformers,
base64-bytestring,
either,
time
exposed-modules:
Obelisk.Frontend
Obelisk.Frontend.Cookie
Expand Down
4 changes: 4 additions & 0 deletions lib/frontend/src/Obelisk/Frontend.hs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ type ObeliskWidget t route m =
, PrebuildAgnostic t route (Client m)
, HasConfigs m
, HasCookies m
, HasCookies (Client m)
, HasSetCookie (Client m)
, MonadIO (Performable m)
)

Expand Down Expand Up @@ -211,6 +213,8 @@ runFrontendWithConfigsAndCurrentRoute mode configs validFullEncoder frontend = d
, MonadFix m
, Prerender DomTimeline (HydrationDomBuilderT s DomTimeline m)
, MonadIO (Performable m)
, HasCookies (Client (HydrationDomBuilderT s DomTimeline m))
, HasSetCookie (Client (HydrationDomBuilderT s DomTimeline m))
)
=> (forall c. HydrationDomBuilderT s DomTimeline m c -> FloatingWidget () c)
-> (forall c. HydrationDomBuilderT s DomTimeline m c -> FloatingWidget () c)
Expand Down
171 changes: 164 additions & 7 deletions lib/frontend/src/Obelisk/Frontend/Cookie.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,72 @@
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-|
Description:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation 💜

Utility and generic functions for getting and setting cookies.

module Obelisk.Frontend.Cookie where
This module provides functions for getting and setting cookies based
on the "Web.Cookie" module. It's recommended to use the 'askCookie' and
'setCookie' functions which Base64 encode their values, because
standard cookies are limited in which characters they can contain.

From <https://www.rfc-editor.org/rfc/rfc6265 RFC 6265: HTTP State Management Mechanism>:

"To maximize compatibility with user agents, servers that wish to
store arbitrary data in a cookie-value SHOULD encode that data, for
example, using Base64"

__Warning__

The 'askCookie' and 'setCookie' functions do not always form a valid State monad. For example, in a 'Snap.Core.Snap' context 'askCookie' examines the HTTP request headers while 'setCookie' changes the outgoing response header.

-}

module Obelisk.Frontend.Cookie
-- ( HasCookies (..),
-- CookiesT (..)
-- )
where

import Control.Monad.Fix
import Control.Monad.IO.Class
import Control.Monad.Primitive
import Control.Monad.Ref
import Control.Monad.IO.Class
import Control.Monad.Trans.Class
import Control.Monad.Trans.Reader
import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Builder (toLazyByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.Either.Combinators
import Data.Text (Text)
import Data.Text.Encoding
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Calendar
import Data.Time.Clock
import Data.Time.Clock.POSIX
import "ghcjs-dom" GHCJS.DOM.Document (Document)
import qualified "ghcjs-dom" GHCJS.DOM.Document as DOM
import GHCJS.DOM.Types (MonadJSM, liftJSM)
import Reflex
import Reflex.Host.Class
import Reflex.Dom.Core
import "ghcjs-dom" GHCJS.DOM.Document (getCookie, Document)
import GHCJS.DOM.Types (MonadJSM)
import Reflex.Host.Class
import Web.Cookie

import Obelisk.Configs
import Obelisk.Route.Frontend

-- | Context in which cookies can be read. It's best to use the
-- 'askCookie' function over 'askCookies', in conjunction with Base64 encoded cookies
-- (e.g. via 'setCookie').
--
-- As of writing this class is implemented in the following contexts:
--
-- * Backend, on the 'Snap.Core.Snap' monad where it lists the cookies of the HTTP request.
--
-- * Frontend, on 'Obelisk.Frontend.ObeliskWidget'.
--
-- * Frontend, on 'Reflex.Dom.Core.Client' (this is the @Client m a@ part of 'Reflex.Dom.Prerender.prerender').
class Monad m => HasCookies m where
askCookies :: m Cookies
default askCookies :: (HasCookies m', m ~ t m', MonadTrans t) => m Cookies
Expand All @@ -50,6 +96,119 @@ instance HasCookies m => HasCookies (RoutedT t r m)
instance HasCookies m => HasCookies (ConfigsT m)
instance HasConfigs m => HasConfigs (CookiesT m)

-- | Retrieve the value of a Base64 encoded cookie.
askCookie :: (HasCookies m) => Text -> m (Either GetCookieFailed Text)
askCookie key = do
v <- lookup (encodeUtf8 key) <$> askCookies
pure $ case v of
Nothing -> Left GetCookieFailed_NotFound
Just c -> mapBoth GetCookieFailed_Base64DecodeFailed decodeUtf8 $
B64.decode c

data GetCookieFailed
= GetCookieFailed_NotFound
| GetCookieFailed_Base64DecodeFailed String
deriving (Eq, Show, Read)

-- | Contexts in which cookies can be set. It's best to use the Base64
-- encoding 'setCookie' function over 'setCookieRaw', because the
-- former doesn't fail silently on unsupported characters. Be warned
-- that browsers still limit the (total) size of cookies. This limit
-- is unchecked.
--
-- As of writing this class is implemented in the following contexts:
--
-- * Backend, on the 'Snap.Core.Snap' monad where it sets cookies for the HTTP response.
--
-- * Frontend, on 'Reflex.Dom.Core.Client' (this is the "Client m a"
-- part of 'Reflex.Dom.Prerender.prerender'). This sets cookies
-- client-side.
class (Monad m) => HasSetCookie m where
setCookieRaw :: SetCookie -> m ()
default setCookieRaw :: (HasSetCookie m', m ~ t m', MonadTrans t) => SetCookie -> m ()
setCookieRaw c = lift (setCookieRaw c)

instance (MonadJSM m, RawDocument (DomBuilderSpace (HydrationDomBuilderT s t m)) ~ Document) => HasSetCookie (HydrationDomBuilderT s t m) where
setCookieRaw cookie = do
doc <- askDocument
liftJSM $ DOM.setCookie doc $ decodeUtf8 $ LBS.toStrict $ toLazyByteString $ renderSetCookie cookie


instance (MonadJSM m, HasDocument m, DOM.IsDocument (RawDocument (DomBuilderSpace m))) => HasSetCookie (HydratableT m) where
setCookieRaw cookie = do
doc <- askDocument
liftJSM $ DOM.setCookie doc $ decodeUtf8 $ LBS.toStrict $ toLazyByteString $ renderSetCookie cookie


instance HasSetCookie m => HasSetCookie (BehaviorWriterT t w m)
instance HasSetCookie m => HasSetCookie (DynamicWriterT t w m)
instance HasSetCookie m => HasSetCookie (EventWriterT t w m)
instance HasSetCookie m => HasSetCookie (PostBuildT t m)
instance HasSetCookie m => HasSetCookie (QueryT t q m)
instance HasSetCookie m => HasSetCookie (ReaderT r m)
instance HasSetCookie m => HasSetCookie (RequesterT t request response m)
instance HasSetCookie m => HasSetCookie (RouteToUrlT t m)
instance HasSetCookie m => HasSetCookie (SetRouteT t r m)
instance HasSetCookie m => HasSetCookie (StaticDomBuilderT t m)
instance HasSetCookie m => HasSetCookie (TriggerEventT t m)
instance HasSetCookie m => HasSetCookie (RoutedT t r m)
instance HasSetCookie m => HasSetCookie (ConfigsT m)
instance HasSetCookie m => HasSetCookie (CookiesT m)
instance (MonadJSM m, RawDocument (DomBuilderSpace (HydrationDomBuilderT s t m)) ~ Document) => HasCookies (HydrationDomBuilderT s t m) where
askCookies = fmap (parseCookies . encodeUtf8) $ DOM.getCookie =<< askDocument

instance (MonadJSM m, HasDocument m, DOM.IsDocument (RawDocument (DomBuilderSpace m))) => HasCookies (HydratableT m) where
askCookies = fmap (parseCookies . encodeUtf8) $ DOM.getCookie =<< askDocument

-- | Store a cookie which will be Base64 encoded.
setCookie :: (HasSetCookie m) => SetCookie -> m ()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we name this encodeAndSetCookie? I don't want it to be surprising that this function re-encodes your cookie value for you.

setCookie = setCookieRaw . (\c -> c { setCookieValue = B64.encode (setCookieValue c) })



-- TODO: Make generic over both frontend and backend.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo in code >:|

can you make an issue instead?

-- | Make a cookie with sensible defaults.
defaultCookie
:: (MonadJSM m)
=> Text -- ^ Cookie key.
-> Maybe Text -- ^ Cookie value ('Nothing' clears it).
-> m SetCookie
defaultCookie key mv = do
currentProtocol <- Reflex.Dom.Core.getLocationProtocol
pure $ case mv of
Nothing -> def
{ setCookieName = encodeUtf8 key
, setCookieValue = ""
, setCookieExpires = Just $ posixSecondsToUTCTime 0
}
Just val -> def
{ setCookieName = encodeUtf8 key
, setCookieValue = encodeUtf8 val
-- We don't want these to expire, but browsers don't support
-- non-expiring cookies. Some systems have trouble representing dates
-- past 2038, so use 2037.
, setCookieExpires = Just $ UTCTime (fromGregorian 2037 1 1) 0
, setCookieSecure = currentProtocol == "https:"
-- This helps prevent CSRF attacks; we don't want strict, because it
-- would prevent links to the page from working; lax is secure enough,
-- because we don't take dangerous actions simply by executing a GET
-- request.
, setCookieSameSite = if currentProtocol == "file:"
then Nothing
else Just sameSiteLax
}

-- | Clear a cookie.
clearCookie :: (HasSetCookie m)
=> Text -- ^ Cookie key
-> m ()
clearCookie key = do
setCookie (def { setCookieName = encodeUtf8 key
, setCookieValue = ""
, setCookieExpires = Just $ posixSecondsToUTCTime 0
})

-- | A trivial implementation of 'HasCookies' as a Reader monad.
newtype CookiesT m a = CookiesT { unCookiesT :: ReaderT Cookies m a }
deriving
( Functor
Expand Down Expand Up @@ -99,5 +258,3 @@ mapCookiesT
-> CookiesT n a
mapCookiesT f (CookiesT x) = CookiesT $ mapReaderT f x

instance (MonadJSM m, RawDocument (DomBuilderSpace (HydrationDomBuilderT s t m)) ~ Document) => HasCookies (HydrationDomBuilderT s t m) where
askCookies = fmap (parseCookies . encodeUtf8) $ getCookie =<< askDocument