module Network.Wai.Application.Static
(
MimeType
, defaultMimeType
, Extension
, MimeMap
, takeExtensions
, defaultMimeTypes
, mimeTypeByExt
, defaultMimeTypeByExt
, Pieces
, pathFromPieces
, MetaData (..)
, mdIsFile
, getMetaData
, Listing
, defaultListing
, defaultDirListing
, staticApp
, staticAppPieces
, StaticSettings (..)
, defaultStaticSettings
, defaultPublicSettings
, CacheSettings (..)
, unfixPathName
) where
import qualified Network.Wai as W
import qualified Network.HTTP.Types as H
import Data.Map (Map)
import qualified Data.Map as Map
import Data.ByteString (ByteString)
import System.Directory (doesFileExist, doesDirectoryExist, getDirectoryContents)
import qualified Data.ByteString.Char8 as S8
import qualified Data.ByteString.Lazy as L
import Data.ByteString.Lazy.Char8 ()
import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
import System.Posix.Types (FileOffset, EpochTime)
import Control.Monad.IO.Class (liftIO)
import Data.Maybe (catMaybes, isNothing, isJust)
import Text.Blaze ((!))
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Renderer.Utf8 as HU
import qualified Text.Blaze.Html5.Attributes as A
import Blaze.ByteString.Builder (toByteString, copyByteString)
import Data.Monoid (mappend)
import Data.Time
import Data.Time.Clock.POSIX
import System.Locale (defaultTimeLocale)
import Data.List (sortBy)
import Data.FileEmbed (embedFile)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
#ifdef PRINT
import Debug.Trace
debug :: (Show a) => a -> a
debug a = trace ("DEBUG: " ++ show a) a
#else
trace :: String -> a -> a
trace _ x = x
debug :: a -> a
debug = id
#endif
takeExtensions :: FilePath -> [String]
takeExtensions s =
case break (== '.') s of
(_, '.':x) -> x : takeExtensions x
(_, _) -> []
type MimeType = ByteString
type Extension = String
type MimeMap = Map Extension MimeType
defaultMimeType :: MimeType
defaultMimeType = "application/octet-stream"
defaultMimeTypes :: MimeMap
defaultMimeTypes = Map.fromList [
( "apk" , "application/vnd.android.package-archive" ),
( "asc" , "text/plain" ),
( "asf" , "video/x-ms-asf" ),
( "asx" , "video/x-ms-asf" ),
( "avi" , "video/x-msvideo" ),
( "bz2" , "application/x-bzip" ),
( "c" , "text/plain" ),
( "class" , "application/octet-stream" ),
( "conf" , "text/plain" ),
( "cpp" , "text/plain" ),
( "css" , "text/css" ),
( "cxx" , "text/plain" ),
( "dtd" , "text/xml" ),
( "dvi" , "application/x-dvi" ),
( "epub" , "application/epub+zip" ),
( "gif" , "image/gif" ),
( "gz" , "application/x-gzip" ),
( "hs" , "text/plain" ),
( "htm" , "text/html" ),
( "html" , "text/html" ),
( "ico" , "image/vnd.microsoft.icon" ),
( "jar" , "application/x-java-archive" ),
( "jpeg" , "image/jpeg" ),
( "jpg" , "image/jpeg" ),
( "js" , "text/javascript" ),
( "log" , "text/plain" ),
( "m3u" , "audio/x-mpegurl" ),
( "mov" , "video/quicktime" ),
( "mp3" , "audio/mpeg" ),
( "mpeg" , "video/mpeg" ),
( "mpg" , "video/mpeg" ),
( "ogg" , "application/ogg" ),
( "pac" , "application/x-ns-proxy-autoconfig" ),
( "pdf" , "application/pdf" ),
( "png" , "image/png" ),
( "ps" , "application/postscript" ),
( "qt" , "video/quicktime" ),
( "sig" , "application/pgp-signature" ),
( "spl" , "application/futuresplash" ),
( "svg" , "image/svg+xml" ),
( "swf" , "application/x-shockwave-flash" ),
( "tar" , "application/x-tar" ),
( "tar.bz2" , "application/x-bzip-compressed-tar" ),
( "tar.gz" , "application/x-tgz" ),
( "tbz" , "application/x-bzip-compressed-tar" ),
( "text" , "text/plain" ),
( "tgz" , "application/x-tgz" ),
( "torrent" , "application/x-bittorrent" ),
( "ttf" , "application/x-font-truetype" ),
( "txt" , "text/plain" ),
( "wav" , "audio/x-wav" ),
( "wax" , "audio/x-ms-wax" ),
( "wma" , "audio/x-ms-wma" ),
( "wmv" , "video/x-ms-wmv" ),
( "xbm" , "image/x-xbitmap" ),
( "xml" , "text/xml" ),
( "xpm" , "image/x-xpixmap" ),
( "xwd" , "image/x-xwindowdump" ),
( "zip" , "application/zip" )]
mimeTypeByExt :: MimeMap
-> MimeType
-> FilePath
-> MimeType
mimeTypeByExt mm def =
go . takeExtensions
where
go [] = def
go (e:es) =
case Map.lookup e mm of
Nothing -> go es
Just mt -> mt
defaultMimeTypeByExt :: FilePath -> MimeType
defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
data CheckPieces
= Redirect Pieces
| Forbidden
| NotFound
| FileResponse FilePath
| NotModified
| DirectoryResponse FilePath
| SendContent MimeType L.ByteString
deriving Show
safeInit :: [a] -> [a]
safeInit [] = []
safeInit xs = init xs
filterButLast :: (a -> Bool) -> [a] -> [a]
filterButLast _ [] = []
filterButLast _ [x] = [x]
filterButLast f (x:xs)
| f x = x : filterButLast f xs
| otherwise = filterButLast f xs
unsafe :: T.Text -> Bool
unsafe s | T.null s = False
| T.head s == '.' = True
| otherwise = T.any (== '/') s
stripTrailingSlash :: FilePath -> FilePath
stripTrailingSlash "/" = ""
stripTrailingSlash "" = ""
stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
type Pieces = [T.Text]
relativeDirFromPieces :: Pieces -> T.Text
relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces)
pathFromPieces :: FilePath -> Pieces -> FilePath
pathFromPieces prefix pieces =
concat $ prefix : map ((:) '/') (map unfixPathName $ map T.unpack pieces)
checkPieces :: FilePath
-> [FilePath]
-> Pieces
-> CacheSettings
-> W.Request
-> IO CheckPieces
checkPieces _ _ [".hidden", "folder.png"] _ _ =
return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
checkPieces _ _ [".hidden", "haskell.png"] _ _ =
return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
checkPieces prefix indices pieces cache req
| any unsafe pieces = return Forbidden
| any T.null $ safeInit pieces =
return $ Redirect $ filterButLast (not . T.null) pieces
| otherwise = do
let fp = pathFromPieces prefix pieces
let (isFile, isFolder) =
case () of
()
| null pieces -> (True, True)
| T.null (last pieces) -> (False, True)
| otherwise -> (True, False)
if not isFile then uncached fp isFile isFolder
else
case cache of
ETag ioLookup -> do
let mlastEtag = lookup "If-None-Match" (W.requestHeaders req)
metag <- ioLookup fp
case debug (metag, mlastEtag) of
(Just hash, Just lastHash) | hash == lastHash -> return NotModified
_ -> trace "ETAG: no cache match" uncached fp isFile isFolder
Forever isStaticFile ->
if isStaticFile fp (S8.drop 1 $ W.rawQueryString req) &&
(isJust $ lookup "If-Modified-Since" (W.requestHeaders req)) &&
(isNothing $ lookup "If-Unmodified-Since" (W.requestHeaders req))
then return NotModified
else trace "Static: no cache match" uncached fp isFile isFolder
NoCache -> trace "NoCache" uncached fp isFile isFolder
where
uncached fp isFile isFolder = do
fe <- doesFileExist $ stripTrailingSlash fp
case (fe, isFile) of
(True, True) -> return $ FileResponse fp
(True, False) -> return $ Redirect $ init pieces
(False, _) -> do
de <- doesDirectoryExist fp
if not de
then return NotFound
else do
x <- checkIndices fp indices
case x of
Just index -> return $ Redirect $ setLast pieces (T.pack index)
Nothing ->
if isFolder
then return $ DirectoryResponse fp
else return $ Redirect $ pieces ++ [""]
setLast [] x = [x]
setLast [""] x = [x]
setLast (a:b) x = a : setLast b x
checkIndices _ [] = return Nothing
checkIndices fp (i:is) = do
let fp' = fp ++ '/' : i
fe <- doesFileExist fp'
if fe
then return $ Just i
else checkIndices fp is
type Listing = (Pieces -> FilePath -> IO L.ByteString)
data StaticDirListing = ListingForbidden | StaticDirListing {
ssListing :: Listing
, ssIndices :: [FilePath]
}
defaultDirListing :: StaticDirListing
defaultDirListing = StaticDirListing defaultListing []
type CheckHashParam = (FilePath -> S8.ByteString -> Bool)
data CacheSettings = NoCache | Forever CheckHashParam | ETag (FilePath -> IO (Maybe S8.ByteString))
oneYear :: Int
oneYear = 60 * 60 * 24 * 365
data StaticSettings = StaticSettings
{ ssFolder :: FilePath
, ssMkRedirect :: Pieces -> ByteString -> S8.ByteString
, ssGetMimeType :: FilePath -> IO MimeType
, ssDirListing :: StaticDirListing
, ssCacheSettings :: CacheSettings
}
defaultMkRedirect :: Pieces -> ByteString -> S8.ByteString
defaultMkRedirect pieces newPath =
let relDir = TE.encodeUtf8 (relativeDirFromPieces pieces) in
S8.append relDir (if (S8.last relDir) == '/' && (S8.head newPath) == '/'
then S8.tail newPath
else newPath)
defaultStaticSettings :: CacheSettings -> StaticSettings
defaultStaticSettings isStaticFile = StaticSettings { ssFolder = "static"
, ssMkRedirect = defaultMkRedirect
, ssGetMimeType = return . defaultMimeTypeByExt
, ssDirListing = defaultDirListing
, ssCacheSettings = isStaticFile
}
defaultPublicSettings :: CacheSettings -> StaticSettings
defaultPublicSettings etags = StaticSettings { ssFolder = "public"
, ssMkRedirect = defaultMkRedirect
, ssGetMimeType = return . defaultMimeTypeByExt
, ssDirListing = ListingForbidden
, ssCacheSettings = etags
}
staticApp :: StaticSettings -> W.Application
staticApp set req = do
let pieces = W.pathInfo req
staticAppPieces set pieces req
status304, statusNotModified :: H.Status
status304 = H.Status 304 "Not Modified"
statusNotModified = status304
staticAppPieces :: StaticSettings -> Pieces -> W.Application
staticAppPieces _ _ req
| W.requestMethod req /= "GET" = return $ W.responseLBS
H.status405
[("Content-Type", "text/plain")]
"Only GET is supported"
staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
let cache = ssCacheSettings ss
let indices = case ssDirListing ss of
StaticDirListing _ is -> is
ListingForbidden -> []
cp <- checkPieces (ssFolder ss) indices pieces cache req
case cp of
FileResponse fp -> do
mimetype <- (ssGetMimeType ss) fp
filesize <- fileSize `fmap` getFileStatus fp
ch <- setCacheHeaders cache fp
return $ W.ResponseFile H.status200
( [ ("Content-Type", mimetype)
, ("Content-Length", S8.pack $ show filesize)
] ++ ch ) fp Nothing
NotModified ->
return $ W.responseLBS statusNotModified
[ ("Content-Type", "text/plain")
] "Not Modified"
DirectoryResponse fp ->
case ssDirListing ss of
StaticDirListing f _ -> do
lbs <- f pieces fp
return $ W.responseLBS H.status200
[ ("Content-Type", "text/html; charset=utf-8")
] lbs
ListingForbidden -> return $ W.responseLBS H.status403
[ ("Content-Type", "text/plain")
] "Directory listings disabled"
SendContent mt lbs -> do
return $ W.responseLBS H.status200
[ ("Content-Type", mt)
] lbs
Redirect pieces' -> do
let loc = (ssMkRedirect ss) pieces' $ toByteString (H.encodePathSegments pieces')
let loc' =
toByteString $
foldr mappend (H.encodePathSegments pieces')
$ map (const $ copyByteString "../") $ drop 1 pieces
return $ W.responseLBS H.status301
[ ("Content-Type", "text/plain")
, ("Location", loc)
] "Redirect"
Forbidden -> return $ W.responseLBS H.status403
[ ("Content-Type", "text/plain")
] "Forbidden"
NotFound -> return $ W.responseLBS H.status404
[ ("Content-Type", "text/plain")
] "File not found"
where
setCacheHeaders :: CacheSettings -> FilePath -> IO H.ResponseHeaders
setCacheHeaders (Forever isStaticFile) fp = return $
if isStaticFile fp (S8.drop 1 $ W.rawQueryString req)
then [("Cache-Control", S8.append "max-age=" $ S8.pack $ show oneYear)]
else []
setCacheHeaders NoCache _ = return []
setCacheHeaders (ETag ioLookup) fp = do
etag <- ioLookup fp
return $ case etag of
Just hash -> [("ETag", hash)]
Nothing -> []
fixPathName :: FilePath -> FilePath
#if defined(mingw32_HOST_OS)
fixPathName = id
#else
fixPathName = T.unpack . TE.decodeUtf8With TEE.lenientDecode . S8.pack
#endif
unfixPathName :: FilePath -> FilePath
#if defined(mingw32_HOST_OS)
unfixPathName = id
#else
unfixPathName = S8.unpack . TE.encodeUtf8 . T.pack
#endif
defaultListing :: Listing
defaultListing pieces localPath = do
fps <- getDirectoryContents localPath
fps' <- mapM (getMetaData localPath) fps
let isTop = null pieces || pieces == [""]
let fps'' = if isTop then fps' else Just (FolderMetaData "..") : fps'
return $ HU.renderHtml
$ H.html $ do
H.head $ do
let title = T.unpack $ T.intercalate "/" pieces
let title' = if null title then "root folder" else title
H.title $ H.string title'
H.style $ H.string $ unlines [ "table { margin: 0 auto; width: 760px; border-collapse: collapse; font-family: 'sans-serif'; }"
, "table, th, td { border: 1px solid #353948; }"
, "td.size { text-align: right; font-size: 0.7em; width: 50px }"
, "td.date { text-align: right; font-size: 0.7em; width: 130px }"
, "td { padding-right: 1em; padding-left: 1em; }"
, "th.first { background-color: white; width: 24px }"
, "td.first { padding-right: 0; padding-left: 0; text-align: center }"
, "tr { background-color: white; }"
, "tr.alt { background-color: #A3B5BA}"
, "th { background-color: #3C4569; color: white; font-size: 1.125em; }"
, "h1 { width: 760px; margin: 1em auto; font-size: 1em; font-family: sans-serif }"
, "img { width: 20px }"
, "a { text-decoration: none }"
]
H.body $ do
H.h1 $ showFolder $ map T.unpack $ filter (not . T.null) pieces
renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
where
image x = T.unpack $ T.concat [(relativeDirFromPieces pieces), ".hidden/", x, ".png"]
folderSrc = image "folder"
haskellSrc = image "haskell"
showName "" = "root"
showName x = x
showFolder [] = H.string "error: Unexpected showFolder []"
showFolder [x] = H.string $ showName x
showFolder (x:xs) = do
let href = concat $ replicate (length xs) "../"
H.a ! A.href (H.stringValue href) $ H.string $ showName x
H.string " / "
showFolder xs
renderDirectoryContentsTable :: String
-> String
-> [MetaData]
-> H.Html
renderDirectoryContentsTable haskellSrc folderSrc fps =
H.table $ do H.thead $ do H.th ! (A.class_ $ H.stringValue "first") $ H.img ! (A.src $ H.stringValue haskellSrc)
H.th $ H.string "Name"
H.th $ H.string "Modified"
H.th $ H.string "Size"
H.tbody $ mapM_ mkRow (zip (sortBy sortMD fps) $ cycle [False, True])
where
sortMD FolderMetaData{} FileMetaData{} = LT
sortMD FileMetaData{} FolderMetaData{} = GT
sortMD x y = mdName x `compare` mdName y
mkRow :: (MetaData, Bool) -> H.Html
mkRow (md, alt) =
(if alt then (! A.class_ (H.stringValue "alt")) else id) $
H.tr $ do
H.td ! A.class_ (H.stringValue "first")
$ if mdIsFile md
then return ()
else H.img ! A.src (H.stringValue folderSrc)
! A.alt (H.stringValue "Folder")
H.td (H.a ! A.href (H.stringValue $ mdName' md ++ if mdIsFile md then "" else "/") $ H.string $ mdName' md)
H.td ! A.class_ (H.stringValue "date") $ H.string $
if mdIsFile md
then formatCalendarTime defaultTimeLocale "%d-%b-%Y %X" $ mdModified md
else ""
H.td ! A.class_ (H.stringValue "size") $ H.string $
if mdIsFile md
then prettyShow $ mdSize md
else ""
formatCalendarTime a b c = formatTime a b $ posixSecondsToUTCTime (realToFrac c :: POSIXTime)
prettyShow x
| x > 1024 = prettyShowK $ x `div` 1024
| otherwise = addCommas "B" x
prettyShowK x
| x > 1024 = prettyShowM $ x `div` 1024
| otherwise = addCommas "KB" x
prettyShowM x
| x > 1024 = prettyShowG $ x `div` 1024
| otherwise = addCommas "MB" x
prettyShowG x = addCommas "GB" x
addCommas s = (++ (' ' : s)) . reverse . addCommas' . reverse . show
addCommas' (a:b:c:d:e) = a : b : c : ',' : addCommas' (d : e)
addCommas' x = x
mdName' :: MetaData -> FilePath
mdName' = fixPathName . mdName
data MetaData =
FileMetaData
{ mdName :: FilePath
, mdModified :: EpochTime
, mdSize :: FileOffset
}
| FolderMetaData
{ mdName :: FilePath
}
deriving Show
mdIsFile :: MetaData -> Bool
mdIsFile FileMetaData{} = True
mdIsFile FolderMetaData{} = False
getMetaData :: FilePath
-> FilePath
-> IO (Maybe MetaData)
getMetaData _ ('.':_) = return Nothing
getMetaData localPath fp = do
let fp' = localPath ++ '/' : fp
fe <- doesFileExist fp'
if fe
then do
fs <- getFileStatus fp'
let modTime = modificationTime fs
let count = fileSize fs
return $ Just $ FileMetaData fp modTime count
else do
de <- doesDirectoryExist fp'
return $ if de then Just (FolderMetaData fp) else Nothing