module Airship.Resource.Static
( FileInfo(..)
, StaticOptions(..)
, staticResource
, allFilesAtRoot
, epochToUTCTime
, directoryTree
) where
#if __GLASGOW_HASKELL__ < 710
import Control.Applicative ((<$>))
#endif
import Airship.Headers (addResponseHeader)
import Airship.Types ( ETag(Strong)
, ResponseBody(ResponseFile)
, Webmachine
, dispatchPath
, halt
)
import Airship.Resource (Resource(..), defaultResource)
import Control.Monad (foldM, when)
import qualified Crypto.Hash.MD5 as MD5
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as Base64URL
import Data.ByteString.Char8 (pack, split)
import Data.Monoid ((<>))
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import qualified Data.Trie as Trie
import Network.HTTP.Media ((//))
import qualified Network.HTTP.Types as HTTP
import qualified Network.Mime as Mime
import qualified System.Directory as D
import System.FilePath (takeFileName)
import qualified System.Posix.Files as Files
import System.IO (IOMode(ReadMode), withBinaryFile)
import System.Posix.Types (EpochTime)
data FileTree = FileTree { tree :: Trie.Trie FileInfo
, root :: T.Text
}
data FileInfo = FileInfo
{ _path :: FilePath
, _size :: Integer
, _lastModified :: UTCTime
, _etag :: ETag
} deriving (Show, Eq, Ord)
data StaticOptions = Cache | NoCache deriving (Eq)
epochToUTCTime :: EpochTime -> UTCTime
epochToUTCTime = posixSecondsToUTCTime . realToFrac
fileETag :: FilePath -> IO ETag
fileETag p = withBinaryFile p ReadMode makeEtag
where makeEtag h = do
let ctx = MD5.init
res <- go ctx h
return (Strong (BS.take 22 (Base64URL.encode (MD5.finalize res))))
go ctx h = do
bs <- BS.hGetSome h 1024
if BS.null bs
then return ctx
else return (MD5.update ctx bs)
filteredDirectory :: FilePath -> IO [FilePath]
filteredDirectory p = filter (not . (`elem` [".", ".."])) <$> D.getDirectoryContents p
allFilesAtRoot :: FilePath -> IO [FilePath]
allFilesAtRoot p = filteredDirectory p >>= foldM folder []
where folder :: [FilePath] -> FilePath -> IO [FilePath]
folder acc f = do
let fullPath = p <> "/" <> f
exists <- D.doesDirectoryExist fullPath
if exists
then do
more <- allFilesAtRoot (p <> "/" <> f)
return (more ++ acc)
else return (fullPath : acc)
regularFileStatus :: [FilePath] -> IO [(FilePath, Files.FileStatus)]
regularFileStatus fs = filter (Files.isRegularFile . snd) <$>
mapM (\f -> (,) f <$> Files.getFileStatus f) fs
fileInfos :: [(FilePath, Files.FileStatus, ETag)] -> [(ByteString, FileInfo)]
fileInfos = map (\(p, s, e) -> (pack p, statusToInfo p s e))
statusToInfo :: FilePath -> Files.FileStatus -> ETag -> FileInfo
statusToInfo p i e = FileInfo { _path = p
, _size = fromIntegral (Files.fileSize i)
, _lastModified = epochToUTCTime (Files.modificationTime i)
, _etag = e
}
directoryTree :: FilePath -> IO FileTree
directoryTree f = do
regularFiles <- allFilesAtRoot f >>= regularFileStatus
etags <- mapM (fileETag . fst) regularFiles
let infos = fileInfos (zipWith (\(a,b) c -> (a,b,c)) regularFiles etags)
return (FileTree (Trie.fromList infos) (T.pack f))
staticResource :: Monad m => StaticOptions -> FilePath -> IO (Resource m)
staticResource options p = staticResource' options <$> directoryTree p
staticResource' :: Monad m => StaticOptions -> FileTree -> Resource m
staticResource' options FileTree{..} = defaultResource
{ allowedMethods = return [ HTTP.methodGet, HTTP.methodHead ]
, resourceExists = getFileInfo >> return True
, generateETag = if options == Cache
then Just . _etag <$> getFileInfo
else return Nothing
, lastModified = if options == Cache
then Just . _lastModified <$> getFileInfo
else return Nothing
, contentTypesProvided = do
fInfo <- getFileInfo
when (options == NoCache) addNoCacheHeaders
let response = return (ResponseFile (_path fInfo) Nothing)
fileName = T.pack (takeFileName (_path fInfo))
fromExtension = Mime.defaultMimeLookup fileName
(a:b:_tl) = split '/' fromExtension
mediaType = a // b
return [ (mediaType, response)
, ("application/octet-stream", response)]
}
where getFileInfo :: Monad m => Webmachine m FileInfo
getFileInfo = do
dispath <- dispatchPath
let key = encodeUtf8 (T.intercalate "/" (root:dispath))
let res = Trie.lookup key tree
case res of
(Just r) -> return r
Nothing -> halt HTTP.status404
addNoCacheHeaders :: Monad m => Webmachine m ()
addNoCacheHeaders = do
addResponseHeader (HTTP.hCacheControl, "no-cache, no-store, must-revalidate")
addResponseHeader ("Pragma", "no-cache")
addResponseHeader ("Expires", "0")