{-# LANGUAGE DeriveDataTypeable #-} -- http://tools.ietf.org/html/rfc2109 module Happstack.Server.Internal.Cookie ( Cookie(..) , CookieLife(..) , calcLife , mkCookie , mkCookieHeader , getCookies , getCookie , getCookies' , getCookie' , parseCookies , cookiesParser ) where import Control.Monad import qualified Data.ByteString.Char8 as C import Data.Char (chr, toLower) import Data.Data (Data, Typeable) import Data.List ((\\), intersperse) import Data.Time.Clock (UTCTime, addUTCTime, diffUTCTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Happstack.Server.Internal.Clock (getApproximateUTCTime) import Text.ParserCombinators.Parsec hiding (token) #if MIN_VERSION_time(1,5,0) import Data.Time.Format (formatTime, defaultTimeLocale) #else import Data.Time.Format (formatTime) import System.Locale (defaultTimeLocale) #endif -- | a type for HTTP cookies. Usually created using 'mkCookie'. data Cookie = Cookie { cookieVersion :: String , cookiePath :: String , cookieDomain :: String , cookieName :: String , cookieValue :: String , secure :: Bool , httpOnly :: Bool } deriving(Show,Eq,Read,Typeable,Data) -- | Specify the lifetime of a cookie. -- -- Note that we always set the max-age and expires headers because -- internet explorer does not honor max-age. You can specific 'MaxAge' -- or 'Expires' and the other will be calculated for you. Choose which -- ever one makes your life easiest. -- data CookieLife = Session -- ^ session cookie - expires when browser is closed | MaxAge Int -- ^ life time of cookie in seconds | Expires UTCTime -- ^ cookie expiration date | Expired -- ^ cookie already expired deriving (Eq, Ord, Read, Show, Typeable) -- convert 'CookieLife' to the argument needed for calling 'mkCookieHeader' calcLife :: CookieLife -> IO (Maybe (Int, UTCTime)) calcLife Session = return Nothing calcLife (MaxAge s) = do now <- getApproximateUTCTime return (Just (s, addUTCTime (fromIntegral s) now)) calcLife (Expires expirationDate) = do now <- getApproximateUTCTime return $ Just (round $ expirationDate `diffUTCTime` now, expirationDate) calcLife Expired = return $ Just (0, posixSecondsToUTCTime 0) -- | Creates a cookie with a default version of 1, empty domain, a -- path of "/", secure == False and httpOnly == False -- -- see also: 'addCookie' mkCookie :: String -- ^ cookie name -> String -- ^ cookie value -> Cookie mkCookie key val = Cookie "1" "/" "" key val False False -- | Set a Cookie in the Result. -- The values are escaped as per RFC 2109, but some browsers may -- have buggy support for cookies containing e.g. @\'\"\'@ or @\' \'@. -- -- Also, it seems that chrome, safari, and other webkit browsers do -- not like cookies which have double quotes around the domain and -- reject/ignore the cookie. So, we no longer quote the domain. -- -- internet explorer does not honor the max-age directive so we set -- both max-age and expires. -- -- See 'CookieLife' and 'calcLife' for a convenient way of calculating -- the first argument to this function. mkCookieHeader :: Maybe (Int, UTCTime) -> Cookie -> String mkCookieHeader mLife cookie = let l = [("Domain=", cookieDomain cookie) ,("Max-Age=", maybe "" (show . max 0 . fst) mLife) ,("expires=", maybe "" (formatTime defaultTimeLocale "%a, %d-%b-%Y %X GMT" . snd) mLife) ,("Path=", cookiePath cookie) ,("Version=", s cookieVersion)] s f | f cookie == "" = "" s f = '\"' : concatMap e (f cookie) ++ "\"" e c | fctl c || c == '"' = ['\\',c] | otherwise = [c] in concat $ intersperse ";" ((cookieName cookie++"="++s cookieValue):[ (k++v) | (k,v) <- l, "" /= v ] ++ (if secure cookie then ["Secure"] else []) ++ (if httpOnly cookie then ["HttpOnly"] else [])) fctl :: Char -> Bool fctl ch = ch == chr 127 || ch <= chr 31 -- | Not an supported api. Takes a cookie header and returns -- either a String error message or an array of parsed cookies parseCookies :: String -> Either String [Cookie] parseCookies str = either (Left . show) Right $ parse cookiesParser str str -- | not a supported api. A parser for RFC 2109 cookies cookiesParser :: GenParser Char st [Cookie] cookiesParser = cookies where -- Parsers based on RFC 2109 cookies = do ws ver<-option "" $ try (cookie_version >>= (\x -> cookieSep >> return x)) cookieList<-(cookie_value ver) `sepBy1` try cookieSep ws eof return cookieList cookie_value ver = do name<-name_parser cookieEq val<-value path<-option "" $ try (cookieSep >> cookie_path) domain<-option "" $ try (cookieSep >> cookie_domain) return $ Cookie ver path domain (low name) val False False cookie_version = cookie_special "$Version" cookie_path = cookie_special "$Path" cookie_domain = cookie_special "$Domain" cookie_special s = do void $ string s cookieEq value cookieSep = ws >> oneOf ",;" >> ws cookieEq = ws >> char '=' >> ws ws = spaces value = word word = try quoted_string <|> try incomp_token <|> return "" -- Parsers based on RFC 2068 quoted_string = do void $ char '"' r <-many ((try quotedPair) <|> (oneOf qdtext)) void $ char '"' return r -- Custom parsers, incompatible with RFC 2068, but more forgiving ;) incomp_token = many1 $ oneOf ((chars \\ ctl) \\ " \t\";") name_parser = many1 $ oneOf ((chars \\ ctl) \\ "= ;,") -- Primitives from RFC 2068 ctl = map chr (127:[0..31]) chars = map chr [0..127] octet = map chr [0..255] text = octet \\ ctl qdtext = text \\ "\"" quotedPair = char '\\' >> anyChar -- | Get all cookies from the HTTP request. The cookies are ordered per RFC from -- the most specific to the least specific. Multiple cookies with the same -- name are allowed to exist. getCookies :: Monad m => C.ByteString -> m [Cookie] getCookies h = getCookies' h >>= either (fail. ("Cookie parsing failed!"++)) return -- | Get the most specific cookie with the given name. Fails if there is no such -- cookie or if the browser did not escape cookies in a proper fashion. -- Browser support for escaping cookies properly is very diverse. getCookie :: Monad m => String -> C.ByteString -> m Cookie getCookie s h = getCookie' s h >>= either (const $ fail ("getCookie: " ++ show s)) return getCookies' :: Monad m => C.ByteString -> m (Either String [Cookie]) getCookies' header | C.null header = return $ Right [] | otherwise = return $ parseCookies (C.unpack header) getCookie' :: Monad m => String -> C.ByteString -> m (Either String Cookie) getCookie' s h = do cs <- getCookies' h return $ do -- Either cooks <- cs case filter (\x->(==) (low s) (cookieName x) ) cooks of [] -> fail "No cookie found" f -> return $ head f low :: String -> String low = map toLower