@@ -1,6 +1,5 @@
1{-# LANGUAGE OverloadedStrings #-}
1{-# LANGUAGE OverloadedStrings #-}
2−{-# LANGUAGE TemplateHaskell #-}
2+{-# LANGUAGE TemplateHaskell, CPP #-}
4-- | Static file serving for WAI.
3-- | Static file serving for WAI.
5module Network.Wai.Application.Static
4module Network.Wai.Application.Static
6 ( -- * Generic, non-WAI code
5 ( -- * Generic, non-WAI code
@@ -15,8 +14,8 @@ module Network.Wai.Application.Static
15 , mimeTypeByExt
14 , mimeTypeByExt
16 , defaultMimeTypeByExt
15 , defaultMimeTypeByExt
17 -- ** Finding files
16 -- ** Finding files
18− , CheckPieces
17+ , Pieces
19− , checkPieces
18+ , pathFromPieces
20 -- ** File/folder metadata
19 -- ** File/folder metadata
21 , MetaData (..)
20 , MetaData (..)
22 , mdIsFile
21 , mdIsFile
@@ -24,10 +23,15 @@ module Network.Wai.Application.Static
24 -- ** Directory listings
23 -- ** Directory listings
26 , defaultListing
25 , defaultListing
27 -- * WAI application
27 -- * WAI application
28− , StaticSettings (..)
29 , staticApp
28 , staticApp
30 , staticAppPieces
29 , staticAppPieces
31+ , StaticSettings (..)
32+ , defaultStaticSettings
33+ , defaultPublicSettings
33import qualified Network.Wai as W
37import qualified Network.Wai as W
@@ -42,7 +46,7 @@ import Data.ByteString.Lazy.Char8 ()
42import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
46import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
43import System.Posix.Types (FileOffset, EpochTime)
47import System.Posix.Types (FileOffset, EpochTime)
44import Control.Monad.IO.Class (liftIO)
48import Control.Monad.IO.Class (liftIO)
45−import Data.Maybe (catMaybes)
49+import Data.Maybe (catMaybes, isNothing, isJust)
47import Text.Blaze ((!))
51import Text.Blaze ((!))
48import qualified Text.Blaze.Html5 as H
52import qualified Text.Blaze.Html5 as H
@@ -63,6 +67,17 @@ import qualified Data.Text as T
63import qualified Data.Text.Encoding as TE
67import qualified Data.Text.Encoding as TE
64import qualified Data.Text.Encoding.Error as TEE
68import qualified Data.Text.Encoding.Error as TEE
72+debug :: (Show a) => a -> a
73+debug a = trace ("DEBUG: " ++ show a) a
75+trace :: String -> a -> a
66-- | A list of all possible extensions, starting from the largest.
81-- | A list of all possible extensions, starting from the largest.
67takeExtensions :: FilePath -> [String]
82takeExtensions :: FilePath -> [String]
68takeExtensions s =
83takeExtensions s =
@@ -157,20 +172,19 @@ defaultMimeTypeByExt :: FilePath -> MimeType
157defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
172defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
159data CheckPieces
174data CheckPieces
160− = Redirect [String]
175+ = Redirect Pieces
161 | Forbidden
176 | Forbidden
162 | NotFound
177 | NotFound
163 | FileResponse FilePath
178 | FileResponse FilePath
164 | DirectoryResponse FilePath
180 | DirectoryResponse FilePath
181+ -- TODO: add file size
165 | SendContent MimeType L.ByteString
182 | SendContent MimeType L.ByteString
166 deriving Show
183 deriving Show
168−anyButLast :: (a -> Bool) -> [a] -> Bool
185+safeInit :: [a] -> [a]
169−anyButLast _ [] = False
186+safeInit [] = []
170−anyButLast _ [_] = False
187+safeInit xs = init xs
172− | p x == True = True
173− | otherwise = anyButLast p xs
175filterButLast :: (a -> Bool) -> [a] -> [a]
189filterButLast :: (a -> Bool) -> [a] -> [a]
176filterButLast _ [] = []
190filterButLast _ [] = []
@@ -179,6 +193,7 @@ filterButLast f (x:xs)
179 | f x = x : filterButLast f xs
193 | f x = x : filterButLast f xs
180 | otherwise = filterButLast f xs
194 | otherwise = filterButLast f xs
182unsafe :: FilePath -> Bool
197unsafe :: FilePath -> Bool
183unsafe ('.':_) = True
198unsafe ('.':_) = True
184unsafe s = any (== '/') s
199unsafe s = any (== '/') s
@@ -188,43 +203,75 @@ stripTrailingSlash "/" = ""
188stripTrailingSlash "" = ""
203stripTrailingSlash "" = ""
189stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
204stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
207+relativeFromPieces :: Pieces -> Text
208+relativeFromPieces pieces = concatMap (const "../") (drop 1 pieces) -- last path which is not a dir
210+pathFromPieces :: FilePath -> Pieces -> FilePath
211+pathFromPieces prefix pieces =
212+ concat $ prefix : map ((:) '/') (map unfixPathName pieces)
191−checkPieces :: FilePath -- ^ static file prefix
214+checkPieces :: FilePath -- ^ static file prefix
192− -> [FilePath] -- ^ List of default index files. Cannot contain slashes.
215+ -> [FilePath] -- ^ List of default index files. Cannot contain slashes.
193− -> [String] -- ^ parsed request
216+ -> Pieces -- ^ parsed request
194 -> IO CheckPieces
219 -> IO CheckPieces
195−checkPieces _ _ [".hidden", "folder.png"] =
220+checkPieces _ _ [".hidden", "folder.png"] _ _ =
196 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
221 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
197−checkPieces _ _ [".hidden", "haskell.png"] =
222+checkPieces _ _ [".hidden", "haskell.png"] _ _ =
198 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
223 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
199−checkPieces prefix indices pieces
224+checkPieces prefix indices pieces cache req
200 | any unsafe pieces = return Forbidden
225 | any unsafe pieces = return Forbidden
201− | anyButLast null pieces =
226+ | any null $ safeInit pieces =
202 return $ Redirect $ filterButLast (not . null) pieces
227 return $ Redirect $ filterButLast (not . null) pieces
203 | otherwise = do
228 | otherwise = do
204− let fp = concat $ prefix : map ((:) '/') (map unfixPathName pieces)
229+ let fp = pathFromPieces prefix pieces
205 let (isFile, isFolder) =
230 let (isFile, isFolder) =
206 case () of
231 case () of
208 | null pieces -> (True, True)
233 | null pieces -> (True, True)
209 | null (last pieces) -> (False, True)
234 | null (last pieces) -> (False, True)
210 | otherwise -> (True, False)
235 | otherwise -> (True, False)
211− fe <- doesFileExist $ stripTrailingSlash fp
236+
237+ if not isFile then uncached fp isFile isFolder
212− case (fe, isFile) of
239+ case cache of
213− (True, True) -> return $ FileResponse fp
240+ ETag ioLookup -> do
214− (True, False) -> return $ Redirect $ init pieces
241+ -- No support for If-Match
215− (False, _) -> do
242+ let mlastEtag = lookup "If-None-Match" (W.requestHeaders req)
216− de <- doesDirectoryExist fp
243+ metag <- ioLookup fp
217− if de
244+ case debug (metag, mlastEtag) of
218− then do
245+ (Just hash, Just lastHash) | hash == lastHash -> return NotModified
219− x <- checkIndices fp indices
246+ _ -> trace "ETAG: no cache match" uncached fp isFile isFolder
220− case x of
247+ Forever isStaticFile ->
221− Just index -> return $ Redirect $ setLast pieces index
248+ if isStaticFile fp (W.queryString req) &&
222− Nothing ->
249+ (isJust $ lookup "If-Modified-Since" (W.requestHeaders req)) &&
223− if isFolder
250+ (isNothing $ lookup "If-Unmodified-Since" (W.requestHeaders req))
224− then return $ DirectoryResponse fp
251+ then return NotModified
225− else return $ Redirect $ pieces ++ [""]
252+ else trace "Static: no cache match" uncached fp isFile isFolder
226− else return NotFound
253+ NoCache -> trace "NoCache" uncached fp isFile isFolder
257+ uncached fp isFile isFolder = do
258+ fe <- doesFileExist $ stripTrailingSlash fp
259+ case (fe, isFile) of
260+ (True, True) -> return $ FileResponse fp
261+ (True, False) -> return $ Redirect $ init pieces
263+ de <- doesDirectoryExist fp
265+ then return NotFound
267+ x <- checkIndices fp indices
269+ Just index -> return $ Redirect $ setLast pieces index
272+ then return $ DirectoryResponse fp
273+ else return $ Redirect $ pieces ++ [""]
228 setLast [] x = [x]
275 setLast [] x = [x]
229 setLast [""] x = [x]
276 setLast [""] x = [x]
230 setLast (a:b) x = a : setLast b x
277 setLast (a:b) x = a : setLast b x
@@ -236,30 +283,101 @@ checkPieces prefix indices pieces
236 then return $ Just i
283 then return $ Just i
237 else checkIndices fp is
284 else checkIndices fp is
239−type Listing = [String] -> FilePath -> IO L.ByteString
286+type Listing = (Pieces -> FilePath -> IO L.ByteString)
288+data StaticDirListing = ListingForbidden | StaticDirListing {
289+ ssListing :: Listing
290+ , ssIndices :: [FilePath]
293+defaultDirListing :: StaticDirListing
294+defaultDirListing = StaticDirListing defaultListing []
296+-- IO is for development mode
297+type CheckHashParam = (FilePath -> S8.ByteString -> Bool)
298+data CacheSettings = NoCache | Forever CheckHashParam | ETag (FilePath -> IO (Maybe S8.ByteString))
300+{-MaxAge S8.ByteString -}
301+{-maxAge :: Int -> CacheSettings-}
302+{-maxAge = MaxAge . S8.pack . show-}
305+oneYear = 60 * 60 * 24 * 365
241data StaticSettings = StaticSettings
307data StaticSettings = StaticSettings
242 { ssFolder :: FilePath
308 { ssFolder :: FilePath
243− , ssIndices :: [FilePath]
309+ , ssMkRedirect :: Pieces -> FilePath -> S8.ByteString
244− , ssListing :: Maybe Listing
245 , ssGetMimeType :: FilePath -> IO MimeType
310 , ssGetMimeType :: FilePath -> IO MimeType
311+ , ssDirListing :: StaticDirListing
312+ , ssCacheSettings :: CacheSettings
315+defaultStaticSettings :: CacheSettings -> StaticSettings
316+defaultStaticSettings isStaticFile = StaticSettings { ssFolder = "static"
317+ , ssMkRedirect = \pieces newPath -> S8.pack $ (relativeFromPieces pieces) ++ newPath
318+ , ssGetMimeType = return . defaultMimeTypeByExt
319+ , ssDirListing = defaultDirListing
320+ , ssCacheSettings = isStaticFile
322+defaultPublicSettings :: CacheSettings -> StaticSettings
323+defaultPublicSettings etags = StaticSettings { ssFolder = "public"
324+ , ssMkRedirect = \pieces newPath -> S8.pack $ (relativeFromPieces pieces) ++ newPath
325+ , ssGetMimeType = return . defaultMimeTypeByExt
326+ , ssDirListing = ListingForbidden
327+ , ssCacheSettings = etags
248staticApp :: StaticSettings -> W.Application
331staticApp :: StaticSettings -> W.Application
249staticApp set req = do
332staticApp set req = do
250 let pieces = W.pathInfo req
333 let pieces = W.pathInfo req
251 staticAppPieces set pieces req
334 staticAppPieces set pieces req
336+status304, statusNotModified :: W.Status
337+status304 = W.Status 304 "Not Modified"
338+statusNotModified = status304
253−staticAppPieces :: StaticSettings -> [T.Text] -> W.Application
340+staticAppPieces :: StaticSettings -> Pieces -> W.Application
254staticAppPieces _ _ req
341staticAppPieces _ _ req
255 | W.requestMethod req /= "GET" = return $ W.responseLBS
342 | W.requestMethod req /= "GET" = return $ W.responseLBS
256 H.status405
343 H.status405
257 [("Content-Type", "text/plain")]
344 [("Content-Type", "text/plain")]
258 "Only GET is supported"
345 "Only GET is supported"
259−staticAppPieces (StaticSettings folder indices mlisting getmime) piecesT _ = liftIO $ do
346+staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
260− let pieces = map T.unpack piecesT -- FIXME stick with Text
347+ let cache = ssCacheSettings ss
348+ let indices = case ssDirListing ss of
349+ StaticDirListing _ is -> is
350+ ListingForbidden -> []
261− cp <- checkPieces folder indices pieces
351+ cp <- checkPieces (ssFolder ss) indices pieces cache req
262− case cp of
352+ case debug cp of
353+ FileResponse fp -> do
354+ mimetype <- (ssGetMimeType ss) fp
355+ filesize <- fileSize `fmap` getFileStatus fp
356+ ch <- setCacheHeaders cache fp
357+ return $ W.ResponseFile W.status200
358+ ( [ ("Content-Type", mimetype)
359+ , ("Content-Length", S8.pack $ show filesize)
360+ ] ++ ch ) fp Nothing
362+ return $ W.responseLBS statusNotModified
363+ [ ("Content-Type", "text/plain")
365+ DirectoryResponse fp ->
366+ case ssDirListing ss of
367+ StaticDirListing f _ -> do
369+ return $ W.responseLBS W.status200
370+ [ ("Content-Type", "text/html; charset=utf-8")
372+ ListingForbidden -> return $ W.responseLBS W.status403
373+ [ ("Content-Type", "text/plain")
374+ ] "Directory listings disabled"
375+ SendContent mt lbs -> do
376+ -- TODO: set caching headers
377+ return $ W.responseLBS W.status200
378+ [ ("Content-Type", mt)
379+ -- TODO: set Content-Length
263 Redirect pieces' -> do
381 Redirect pieces' -> do
264 let loc =
382 let loc =
265 toByteString $
383 toByteString $
@@ -275,26 +393,19 @@ staticAppPieces (StaticSettings folder indices mlisting getmime) piecesT _ = lif
275 NotFound -> return $ W.responseLBS H.status404
393 NotFound -> return $ W.responseLBS H.status404
276 [ ("Content-Type", "text/plain")
394 [ ("Content-Type", "text/plain")
277 ] "File not found"
395 ] "File not found"
278− FileResponse fp -> do
396+ where
279− mimetype <- getmime fp
397+ -- expires header: formatTime "%a, %d-%b-%Y %X GMT"
280− filesize <- fileSize `fmap` getFileStatus fp
398+ setCacheHeaders :: CacheSettings -> FilePath -> IO W.ResponseHeaders
281− return $ W.ResponseFile H.status200
399+ setCacheHeaders (Forever isStaticFile) fp = return $
282− [ ("Content-Type", mimetype)
400+ if isStaticFile fp (W.queryString req)
283− , ("Content-Length", S8.pack $ show filesize)
401+ then [("Cache-Control", S8.append "max-age=" $ S8.pack $ show oneYear)]
284− ] fp Nothing
402+ else []
285− DirectoryResponse fp ->
403+ setCacheHeaders NoCache _ = return []
404+ setCacheHeaders (ETag ioLookup) fp = do
286− case mlisting of
406+ return $ case etag of
287− Just listing -> do
407+ Just hash -> [("ETag", hash)]
288− lbs <- listing pieces fp
408+ Nothing -> []
289− return $ W.responseLBS H.status200
290− [ ("Content-Type", "text/html; charset=utf-8")
292− Nothing -> return $ W.responseLBS H.status403
293− [ ("Content-Type", "text/plain")
294− ] "Directory listings disabled"
295− SendContent mt lbs -> return $ W.responseLBS H.status200
296− [ ("Content-Type", mt)
299fixPathName :: FilePath -> FilePath
410fixPathName :: FilePath -> FilePath
300#if defined(mingw32_HOST_OS)
411#if defined(mingw32_HOST_OS)
@@ -341,7 +452,7 @@ defaultListing pieces localPath = do
341 H.h1 $ showFolder $ "" : filter (not . null) pieces
452 H.h1 $ showFolder $ "" : filter (not . null) pieces
342 renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
453 renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
344− image x = concatMap (const "../") (drop 1 pieces) ++ ".hidden/" ++ x ++ ".png"
455+ image x = (relativeFromPieces pieces) ++ ".hidden/" ++ x ++ ".png"
345 folderSrc = image "folder"
456 folderSrc = image "folder"
346 haskellSrc = image "haskell"
457 haskellSrc = image "haskell"
347 showName "" = "root"
458 showName "" = "root"