module Network.Wai.Middleware.ETag where
import Control.Concurrent.MVar (MVar, newMVar, takeMVar)
import Control.Exception (SomeException, try)
import Control.Monad (liftM)
import Crypto.Hash.MD5 as MD5
import qualified Data.ByteString as BS (ByteString, readFile)
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Char8 as BS8
import qualified Data.HashMap.Strict as M
import Network.HTTP.Date (HTTPDate, epochTimeToHTTPDate,
formatHTTPDate, parseHTTPDate)
import Network.HTTP.Types (Header, ResponseHeaders, status304)
import Network.Wai (Middleware, requestHeaders,
responseLBS)
import Network.Wai.Internal (Request (..), Response (..))
import System.Posix.Types (EpochTime)
import System.PosixCompat.Files (fileSize, getFileStatus,
modificationTime)
etag :: Bool -> MaxAge -> Middleware
etag useCache age app req = do
c <- liftM (ETagContext useCache) (newMVar M.empty)
etag' c age app req
etag' :: ETagContext -> MaxAge -> Middleware
etag' cache age app req =
app req >>= \response -> case response of
rf@(ResponseFile _ _ path _) -> do
r <- hashFileCached cache path
case (r, lookup "if-none-match" $ requestHeaders req) of
(Hash h, Just rh) | h == rh ->
return $ addCacheControl age $ responseLBS status304 [] ""
(Hash h, _) ->
respond age rf [("ETag", h)]
(FileNotFound, _) ->
return rf
(FileTooBig, _) -> do
modTime <- getModificationTimeIfExists path
case (fmap epochTimeToHTTPDate modTime, modifiedSince req) of
(Just mdate, Just lastSent) | mdate == lastSent ->
return $ addCacheControl age $ responseLBS status304 [] ""
(Just mdate, _) ->
respond age rf [("last-modified", formatHTTPDate mdate)]
(Nothing, _) ->
respond age rf []
x ->
return x
respond :: Monad m => MaxAge -> Response -> [Header] -> m Response
respond age res hs =
case res of
(ResponseFile st hs' path part) ->
return $ addCacheControl age $ ResponseFile st (hs ++ hs') path part
x ->
return x
addCacheControl :: MaxAge -> Response -> Response
addCacheControl age res =
case res of
(ResponseFile st hs path part) ->
ResponseFile st (cacheControl age hs) path part
(ResponseBuilder st hs b) ->
ResponseBuilder st (cacheControl age hs) b
(ResponseSource st hs s) ->
ResponseSource st (cacheControl age hs) s
modifiedSince :: Request -> Maybe HTTPDate
modifiedSince req =
lookup "if-modified-since" (requestHeaders req) >>= parseHTTPDate
hashFileCached :: ETagContext -> FilePath -> IO HashResult
hashFileCached ctx path =
if etagCtxUseCache ctx
then
liftM (M.lookup path) (takeMVar $ etagCtxCache ctx) >>= \r-> case r of
Just cachedHash ->
return $ Hash cachedHash
Nothing ->
hashFile path
else
hashFile path
cacheControl :: MaxAge -> ResponseHeaders -> ResponseHeaders
cacheControl maxage =
headerCacheControl . headerExpires
where
headerCacheControl = case maxage of
NoMaxAge ->
id
MaxAgeSeconds i ->
(:) ("Cache-Control", BS8.append "public, max-age=" $ BS8.pack $ show i)
MaxAgeForever ->
cacheControl (MaxAgeSeconds (60 * 60 * 24 * 365))
headerExpires = case maxage of
NoMaxAge ->
id
MaxAgeSeconds _ ->
id
MaxAgeForever ->
(:) ("Expires", "Thu, 31 Dec 2037 23:55:55 GMT")
hashFile :: FilePath -> IO HashResult
hashFile fp = do
size <- liftM fileSize (getFileStatus fp)
if size < 1024 * 1024
then do
res <- try $ liftM (B64.encode . MD5.hash) (BS.readFile fp)
return $ case res of
Left (_ :: SomeException) ->
FileNotFound
Right x ->
Hash x
else
return FileTooBig
getModificationTimeIfExists :: FilePath -> IO (Maybe EpochTime)
getModificationTimeIfExists fp = do
res <- try $ liftM modificationTime (getFileStatus fp)
return $ case res of
Left (_ :: SomeException) -> Nothing
Right x -> Just x
data MaxAge
= NoMaxAge
| MaxAgeSeconds Int
| MaxAgeForever
deriving (Show, Eq, Ord, Read)
data HashResult
= Hash BS.ByteString
| FileTooBig
| FileNotFound
deriving (Show, Eq, Ord, Read)
data ETagContext = ETagContext
{ etagCtxUseCache :: !Bool
, etagCtxCache :: !(MVar (M.HashMap FilePath BS.ByteString))
} deriving (Eq)