module Snap.Util.FileServe
(
getSafePath
, MimeMap
, HandlerMap
, DirectoryConfig(..)
, simpleDirectoryConfig
, defaultDirectoryConfig
, fancyDirectoryConfig
, defaultIndexGenerator
, defaultMimeTypes
, fileType
, serveDirectory
, serveDirectoryWith
, serveFile
, serveFileAs
) where
import Blaze.ByteString.Builder
import Blaze.ByteString.Builder.Char8
import Control.Applicative
import Control.Exception (SomeException, evaluate)
import Control.Monad
import Control.Monad.CatchIO
import Control.Monad.Trans
import Data.Attoparsec.Char8
import qualified Data.ByteString.Char8 as S
import Data.ByteString.Char8 (ByteString)
import Data.ByteString.Internal (c2w)
import Data.Int
import Data.List
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as Map
import Data.Maybe (fromMaybe, isNothing)
import Data.Monoid
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
#if MIN_VERSION_base(4,6,0)
import Prelude hiding (show, Show)
#else
import Prelude hiding (catch, show, Show)
#endif
import qualified Prelude
import System.Directory
import System.FilePath
import System.PosixCompat.Files
import Snap.Core
import Snap.Internal.Debug
import Snap.Internal.Parsing
import Snap.Iteratee hiding (drop)
getSafePath :: MonadSnap m => m FilePath
getSafePath = do
req <- getRequest
let mp = urlDecode $ rqPathInfo req
p <- maybe pass (return . S.unpack) mp
when (not $ isRelative p) pass
let dirs = splitDirectories p
when (elem ".." dirs) pass
return $! joinPath dirs
type HandlerMap m = HashMap FilePath (FilePath -> m ())
type MimeMap = HashMap FilePath ByteString
defaultMimeTypes :: MimeMap
defaultMimeTypes = Map.fromList [
( ".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" ),
( ".gif" , "image/gif" ),
( ".gz" , "application/x-gzip" ),
( ".hs" , "text/plain" ),
( ".htm" , "text/html" ),
( ".html" , "text/html" ),
( ".ico" , "image/x-icon" ),
( ".jar" , "application/x-java-archive" ),
( ".jpeg" , "image/jpeg" ),
( ".jpg" , "image/jpeg" ),
( ".js" , "text/javascript" ),
( ".json" , "application/json" ),
( ".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" ) ]
data DirectoryConfig m = DirectoryConfig {
indexFiles :: [FilePath],
indexGenerator :: FilePath -> m (),
dynamicHandlers :: HandlerMap m,
mimeTypes :: MimeMap,
preServeHook :: FilePath -> m ()
}
snapIndexStyles :: ByteString
snapIndexStyles =
S.intercalate "\n"
[ "body { margin: 0px 0px 0px 0px; font-family: sans-serif }"
, "div.header {"
, "padding: 40px 40px 0px 40px; height:35px;"
, "background:rgb(25,50,87);"
, "background-image:-webkit-gradient("
, "linear,left bottom,left top,"
, "color-stop(0.00, rgb(31,62,108)),"
, "color-stop(1.00, rgb(19,38,66)));"
, "background-image:-moz-linear-gradient("
, "center bottom,rgb(31,62,108) 0%,rgb(19,38,66) 100%);"
, "text-shadow:-1px 3px 1px rgb(16,33,57);"
, "font-size:16pt; letter-spacing: 2pt; color:white;"
, "border-bottom:10px solid rgb(46,93,156) }"
, "div.content {"
, "background:rgb(255,255,255);"
, "background-image:-webkit-gradient("
, "linear,left bottom, left top,"
, "color-stop(0.50, rgb(255,255,255)),"
, "color-stop(1.00, rgb(224,234,247)));"
, "background-image:-moz-linear-gradient("
, "center bottom, white 50%, rgb(224,234,247) 100%);"
, "padding: 40px 40px 40px 40px }"
, "div.footer {"
, "padding: 16px 0px 10px 10px; height:31px;"
, "border-top: 1px solid rgb(194,209,225);"
, "color: rgb(160,172,186); font-size:10pt;"
, "background: rgb(245,249,255) }"
, "table { max-width:100%; margin: 0 auto;" `S.append`
" border-collapse: collapse; }"
, "tr:hover { background:rgb(256,256,224) }"
, "td { border:0; font-family:monospace; padding: 2px 0; }"
, "td.filename, td.type { padding-right: 2em; }"
, "th { border:0; background:rgb(28,56,97);"
, "text-shadow:-1px 3px 1px rgb(16,33,57); color: white}"
]
defaultIndexGenerator :: MonadSnap m
=> MimeMap
-> ByteString
-> FilePath
-> m ()
defaultIndexGenerator mm styles d = do
modifyResponse $ setContentType "text/html; charset=utf-8"
rq <- getRequest
let uri = uriWithoutQueryString rq
let pInfo = rqPathInfo rq
writeBS "<!DOCTYPE html>\n<html>\n<head>"
writeBS "<title>Directory Listing: "
writeBS uri
writeBS "</title>"
writeBS "<style type='text/css'>"
writeBS styles
writeBS "</style></head><body>"
writeBS "<div class=\"header\">Directory Listing: "
writeBS uri
writeBS "</div><div class=\"content\">"
writeBS "<table><tr><th>File Name</th><th>Type</th><th>Last Modified"
writeBS "</th></tr>"
when (pInfo /= "") $
writeBS "<tr><td><a href='../'>..</a></td><td colspan=2>DIR</td></tr>"
entries <- liftIO $ getDirectoryContents d
dirs <- liftIO $ filterM (doesDirectoryExist . (d </>)) entries
files <- liftIO $ filterM (doesFileExist . (d </>)) entries
forM_ (sort $ filter (not . (`elem` ["..", "."])) dirs) $ \f0 -> do
f <- liftIO $ liftM (\s -> T.encodeUtf8 s `mappend` "/") $ packFn f0
writeBS "<tr><td class='filename'><a href='"
writeBS f
writeBS "'>"
writeBS f
writeBS "</a></td><td class='type' colspan=2>DIR</td></tr>"
forM_ (sort files) $ \f0 -> do
f <- liftIO $ liftM T.encodeUtf8 $ packFn f0
stat <- liftIO $ getFileStatus (d </> f0)
tm <- liftIO $ formatHttpTime (modificationTime stat)
writeBS "<tr><td class='filename'><a href='"
writeBS f
writeBS "'>"
writeBS f
writeBS "</a></td><td class='type'>"
writeBS (fileType mm f0)
writeBS "</td><td>"
writeBS tm
writeBS "</tr>"
writeBS "</table></div><div class=\"footer\">Powered by "
writeBS "<b><a href=\"http://snapframework.com\">Snap</a></b></div>"
writeBS "</body>"
where
packFn fp = do
tryFirst [ T.decodeUtf8
, T.decodeUtf16LE
, T.decodeUtf16BE
, T.decodeUtf32LE
, T.decodeUtf32BE
, const (T.pack fp) ]
where
tryFirst [] = error "No valid decoding"
tryFirst (f:fs) =
evaluate (f bs) `catch` \(_::SomeException) -> tryFirst fs
bs = S.pack fp
simpleDirectoryConfig :: MonadSnap m => DirectoryConfig m
simpleDirectoryConfig = DirectoryConfig {
indexFiles = [],
indexGenerator = const pass,
dynamicHandlers = Map.empty,
mimeTypes = defaultMimeTypes,
preServeHook = const $ return ()
}
defaultDirectoryConfig :: MonadSnap m => DirectoryConfig m
defaultDirectoryConfig = DirectoryConfig {
indexFiles = ["index.html", "index.htm"],
indexGenerator = const pass,
dynamicHandlers = Map.empty,
mimeTypes = defaultMimeTypes,
preServeHook = const $ return ()
}
fancyDirectoryConfig :: MonadSnap m => DirectoryConfig m
fancyDirectoryConfig = DirectoryConfig {
indexFiles = ["index.html", "index.htm"],
indexGenerator = defaultIndexGenerator defaultMimeTypes snapIndexStyles,
dynamicHandlers = Map.empty,
mimeTypes = defaultMimeTypes,
preServeHook = const $ return ()
}
serveDirectory :: MonadSnap m
=> FilePath
-> m ()
serveDirectory = serveDirectoryWith defaultDirectoryConfig
serveDirectoryWith :: MonadSnap m
=> DirectoryConfig m
-> FilePath
-> m ()
serveDirectoryWith cfg base = do
b <- directory <|> file <|> redir
when (not b) pass
where
idxs = indexFiles cfg
generate = indexGenerator cfg
mimes = mimeTypes cfg
dyns = dynamicHandlers cfg
pshook = preServeHook cfg
serve f = do
liftIO (doesFileExist f) >>= flip unless pass
let fname = takeFileName f
let staticServe f' = pshook f >> serveFileAs (fileType mimes fname) f'
lookupExt staticServe dyns fname f >> return True <|> return False
directory = do
rq <- getRequest
let uri = uriWithoutQueryString rq
unless ("/" `S.isSuffixOf` uri) pass
rel <- (base </>) <$> getSafePath
b <- liftIO $ doesDirectoryExist rel
if b then do let serveRel f = serve (rel </> f)
foldl' (<|>) pass (Prelude.map serveRel idxs)
<|> (generate rel >> return True)
<|> return False
else return False
file = serve =<< ((base </>) <$> getSafePath)
redir = do
rel <- (base </>) <$> getSafePath
liftIO (doesDirectoryExist rel) >>= flip unless pass
rq <- getRequest
let uri = uriWithoutQueryString rq
let qss = queryStringSuffix rq
let u = S.concat [uri, "/", qss]
redirect u
serveFile :: MonadSnap m
=> FilePath
-> m ()
serveFile fp = serveFileAs (fileType defaultMimeTypes (takeFileName fp)) fp
serveFileAs :: MonadSnap m
=> ByteString
-> FilePath
-> m ()
serveFileAs mime fp = do
reqOrig <- getRequest
let req = if isNothing $ getHeader "range" reqOrig
then deleteHeader "if-range" reqOrig
else reqOrig
let mbH = getHeader "if-modified-since" req
mbIfModified <- liftIO $ case mbH of
Nothing -> return Nothing
(Just s) -> liftM Just $ parseHttpTime s
mbIfRange <- liftIO $ case getHeader "if-range" req of
Nothing -> return Nothing
(Just s) -> liftM Just $ parseHttpTime s
dbg $ "mbIfModified: " ++ Prelude.show mbIfModified
dbg $ "mbIfRange: " ++ Prelude.show mbIfRange
filestat <- liftIO $ getFileStatus fp
let mt = modificationTime filestat
maybe (return $! ()) (\lt -> when (mt <= lt) notModified) mbIfModified
let sz = fromIntegral $ fileSize filestat
lm <- liftIO $ formatHttpTime mt
modifyResponse $ setHeader "Last-Modified" lm
. setHeader "Accept-Ranges" "bytes"
. setContentType mime
let skipRangeCheck = maybe (False)
(\lt -> mt > lt)
mbIfRange
wasRange <- if skipRangeCheck
then return False
else liftSnap $ checkRangeReq req fp sz
dbg $ "was this a range request? " ++ Prelude.show wasRange
unless wasRange $ do
modifyResponse $ setResponseCode 200
. setContentLength sz
liftSnap $ sendFile fp
where
notModified = finishWith $
setResponseCode 304 emptyResponse
lookupExt :: a -> HashMap FilePath a -> FilePath -> a
lookupExt def m f =
if null ext
then def
else fromMaybe (lookupExt def m (drop 1 ext)) mbe
where
ext = takeExtensions f
mbe = Map.lookup ext m
fileType :: MimeMap -> FilePath -> ByteString
fileType = lookupExt defaultMimeType
defaultMimeType :: ByteString
defaultMimeType = "application/octet-stream"
data RangeReq = RangeReq { _rangeFirst :: !Int64
, _rangeLast :: !(Maybe Int64)
}
| SuffixRangeReq { _suffixLength :: !Int64 }
deriving (Eq, Prelude.Show)
rangeParser :: Parser RangeReq
rangeParser = string "bytes=" *>
(byteRangeSpec <|> suffixByteRangeSpec) <*
endOfInput
where
byteRangeSpec = do
start <- parseNum
char '-'
end <- option Nothing $ liftM Just parseNum
return $! RangeReq start end
suffixByteRangeSpec = liftM SuffixRangeReq $ char '-' *> parseNum
checkRangeReq :: (MonadSnap m) => Request -> FilePath -> Int64 -> m Bool
checkRangeReq req fp sz = do
dbg $ "checkRangeReq, fp=" ++ fp ++ ", sz=" ++ Prelude.show sz
maybe (return False)
(\s -> either (const $ return False)
withRange
(fullyParse s rangeParser))
(getHeader "range" req)
where
withRange rng@(RangeReq start mend) = do
dbg $ "withRange: got Range request: " ++ Prelude.show rng
let end = fromMaybe (sz1) mend
dbg $ "withRange: start=" ++ Prelude.show start
++ ", end=" ++ Prelude.show end
if start < 0 || end < start || start >= sz || end >= sz
then send416
else send206 start end
withRange rng@(SuffixRangeReq nbytes) = do
dbg $ "withRange: got Range request: " ++ Prelude.show rng
let end = sz1
let start = sz nbytes
dbg $ "withRange: start=" ++ Prelude.show start
++ ", end=" ++ Prelude.show end
if start < 0 || end < start || start >= sz || end >= sz
then send416
else send206 start end
send206 start end = do
dbg "inside send206"
let len = endstart+1
let crng = toByteString $
mconcat [ fromByteString "bytes "
, fromShow start
, fromWord8 (c2w '-')
, fromShow end
, fromWord8 (c2w '/')
, fromShow sz ]
modifyResponse $ setResponseCode 206
. setHeader "Content-Range" crng
. setContentLength len
dbg $ "send206: sending range (" ++ Prelude.show start
++ "," ++ Prelude.show (end+1) ++ ") to sendFilePartial"
sendFilePartial fp (start,end+1)
return True
send416 = do
dbg "inside send416"
if getHeader "If-Range" req /= Nothing
then return False
else do
let crng = toByteString $
mconcat [ fromByteString "bytes */"
, fromShow sz ]
modifyResponse $ setResponseCode 416
. setHeader "Content-Range" crng
. setContentLength 0
. deleteHeader "Content-Type"
. deleteHeader "Content-Encoding"
. deleteHeader "Transfer-Encoding"
. setResponseBody (enumBuilder mempty)
return True
dbg :: (MonadIO m) => String -> m ()
dbg s = debug $ "FileServe:" ++ s
uriWithoutQueryString :: Request -> ByteString
uriWithoutQueryString rq = S.takeWhile (/= '?') uri
where
uri = rqURI rq
queryStringSuffix :: Request -> ByteString
queryStringSuffix rq = S.concat [ s, qs ]
where
qs = rqQueryString rq
s = if S.null qs then "" else "?"