@@ -2,9 +2,21 @@
2{-# LANGUAGE TemplateHaskell, CPP #-}
2{-# LANGUAGE TemplateHaskell, CPP #-}
3-- | Static file serving for WAI.
3-- | Static file serving for WAI.
4module Network.Wai.Application.Static
4module Network.Wai.Application.Static
5+ ( -- * WAI application
8+ , defaultWebAppSettings
9+ , defaultFileServerSettings
5− ( -- * Generic, non-WAI code
17+ -- * Generic, non-WAI code
6 -- ** Mime types
18 -- ** Mime types
7− MimeType
19+ , MimeType
8 , defaultMimeType
20 , defaultMimeType
9 -- ** Mime type by file extension
21 -- ** Mime type by file extension
10 , Extension
22 , Extension
@@ -14,26 +26,25 @@ module Network.Wai.Application.Static
14 , mimeTypeByExt
26 , mimeTypeByExt
15 , defaultMimeTypeByExt
27 , defaultMimeTypeByExt
16 -- ** Finding files
28 -- ** Finding files
18 , pathFromPieces
31 , pathFromPieces
19− -- ** File/folder metadata
32+ , unfixPathName
23 -- ** Directory listings
33 -- ** Directory listings
25 , defaultListing
35 , defaultListing
26− , defaultDirListing
36+ -- ** Lookup functions
27− -- * WAI application
37+ , fileSystemLookup
28− , staticApp
38+ , embeddedLookup
29− , staticAppPieces
39+ -- ** Embedded
30− -- ** Settings
40+ , Embedded
31− , StaticSettings (..)
41+ , EmbeddedEntry (..)
32− , defaultStaticSettings
42+ , toEmbedded
33− , defaultPublicSettings
43+ -- ** Redirecting
34− , CacheSettings (..)
44+ , defaultMkRedirect
35− -- should be moved to common helper
45+ -- * Other data types
36− , unfixPathName
46+ , File (..)
39import qualified Network.Wai as W
50import qualified Network.Wai as W
@@ -46,39 +57,41 @@ import qualified Data.ByteString.Char8 as S8
46import qualified Data.ByteString.Lazy as L
57import qualified Data.ByteString.Lazy as L
47import Data.ByteString.Lazy.Char8 ()
58import Data.ByteString.Lazy.Char8 ()
48import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
59import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
49−import System.Posix.Types (FileOffset, EpochTime)
60+import System.Posix.Types (EpochTime)
50import Control.Monad.IO.Class (liftIO)
61import Control.Monad.IO.Class (liftIO)
51−import Data.Maybe (catMaybes, isNothing, isJust)
62+import qualified Crypto.Hash.MD5 as MD5
63+import Control.Monad (filterM)
53import Text.Blaze ((!))
65import Text.Blaze ((!))
54import qualified Text.Blaze.Html5 as H
66import qualified Text.Blaze.Html5 as H
55import qualified Text.Blaze.Renderer.Utf8 as HU
67import qualified Text.Blaze.Renderer.Utf8 as HU
56import qualified Text.Blaze.Html5.Attributes as A
68import qualified Text.Blaze.Html5.Attributes as A
58−import Blaze.ByteString.Builder (toByteString, copyByteString)
70+import Blaze.ByteString.Builder (toByteString, fromByteString)
59−import Data.Monoid (mappend)
61import Data.Time
72import Data.Time
62import Data.Time.Clock.POSIX
73import Data.Time.Clock.POSIX
63import System.Locale (defaultTimeLocale)
74import System.Locale (defaultTimeLocale)
65−import Data.List (sortBy)
66import Data.FileEmbed (embedFile)
76import Data.FileEmbed (embedFile)
68import qualified Data.Text as T
78import qualified Data.Text as T
69import qualified Data.Text.Encoding as TE
79import qualified Data.Text.Encoding as TE
70import qualified Data.Text.Encoding.Error as TEE
80import qualified Data.Text.Encoding.Error as TEE
72−#ifdef PRINT
82+import Control.Arrow ((&&&), second)
83+import Data.List (groupBy, sortBy, find)
84+import Data.Function (on)
85+import Data.Ord (comparing)
86+import qualified Data.ByteString.Base64 as B64
87+import Data.Either (rights)
88+import Data.Maybe (isJust, fromJust)
73import Debug.Trace
91import Debug.Trace
74debug :: (Show a) => a -> a
92debug :: (Show a) => a -> a
75debug a = trace ("DEBUG: " ++ show a) a
93debug a = trace ("DEBUG: " ++ show a) a
77−trace :: String -> a -> a
83-- | A list of all possible extensions, starting from the largest.
96-- | A list of all possible extensions, starting from the largest.
84takeExtensions :: FilePath -> [String]
97takeExtensions :: FilePath -> [String]
@@ -152,6 +165,7 @@ defaultMimeTypes = Map.fromList [
152 ( "wma" , "audio/x-ms-wma" ),
165 ( "wma" , "audio/x-ms-wma" ),
153 ( "wmv" , "video/x-ms-wmv" ),
166 ( "wmv" , "video/x-ms-wmv" ),
154 ( "xbm" , "image/x-xbitmap" ),
167 ( "xbm" , "image/x-xbitmap" ),
168+ ( "xhtml" , "application/xhtml+xml" ),
155 ( "xml" , "text/xml" ),
169 ( "xml" , "text/xml" ),
156 ( "xpm" , "image/x-xpixmap" ),
170 ( "xpm" , "image/x-xpixmap" ),
157 ( "xwd" , "image/x-xwindowdump" ),
171 ( "xwd" , "image/x-xwindowdump" ),
@@ -173,16 +187,16 @@ mimeTypeByExt mm def =
173defaultMimeTypeByExt :: FilePath -> MimeType
187defaultMimeTypeByExt :: FilePath -> MimeType
174defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
188defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
176−data CheckPieces
190+data CheckPieces =
191+ -- | Just the etag hash or Nothing for no etag hash
177− = Redirect Pieces
192+ Redirect Pieces (Maybe ByteString)
178 | Forbidden
193 | Forbidden
179 | NotFound
194 | NotFound
180− | FileResponse FilePath
195+ | FileResponse File H.ResponseHeaders
181 | NotModified
196 | NotModified
182− | DirectoryResponse FilePath
197+ | DirectoryResponse Folder
183 -- TODO: add file size
198 -- TODO: add file size
184 | SendContent MimeType L.ByteString
199 | SendContent MimeType L.ByteString
187safeInit :: [a] -> [a]
201safeInit :: [a] -> [a]
188safeInit [] = []
202safeInit [] = []
@@ -206,176 +220,362 @@ stripTrailingSlash "/" = ""
206stripTrailingSlash "" = ""
220stripTrailingSlash "" = ""
207stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
221stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
209−type Pieces = [T.Text]
223+data Piece = Piece
224+ { pieceRaw :: FilePath
225+ , piecePretty :: T.Text
227+ deriving (Eq, Ord, Show)
228+type Pieces = [Piece]
210relativeDirFromPieces :: Pieces -> T.Text
230relativeDirFromPieces :: Pieces -> T.Text
211relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces) -- last piece is not a dir
231relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces) -- last piece is not a dir
213pathFromPieces :: FilePath -> Pieces -> FilePath
233pathFromPieces :: FilePath -> Pieces -> FilePath
214pathFromPieces prefix pieces =
234pathFromPieces prefix pieces =
215− concat $ prefix : map ((:) '/') (map unfixPathName $ map T.unpack pieces)
235+ concat $ prefix : map ((:) '/') (map pieceRaw pieces)
217−checkPieces :: FilePath -- ^ static file prefix
237+checkSpecialDirListing :: Pieces -> Maybe CheckPieces
238+checkSpecialDirListing [Piece _ ".hidden", Piece _ "folder.png"] =
239+ Just $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
240+checkSpecialDirListing [Piece _ ".hidden", Piece _ "haskell.png"] =
241+ Just $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
242+checkSpecialDirListing _ = Nothing
244+checkPieces :: (Pieces -> IO FileLookup) -- ^ file lookup function
218− -> [FilePath] -- ^ List of default index files. Cannot contain slashes.
245+ -> [T.Text] -- ^ List of default index files. Cannot contain slashes.
219− -> Pieces -- ^ parsed request
246+ -> Pieces -- ^ parsed request
221 -> W.Request
247 -> W.Request
222 -> IO CheckPieces
249 -> IO CheckPieces
223−checkPieces _ _ [".hidden", "folder.png"] _ _ =
250+checkPieces fileLookup indices pieces req maxAge
224− return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
225−checkPieces _ _ [".hidden", "haskell.png"] _ _ =
226− return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
227−checkPieces prefix indices pieces cache req
228− | any unsafe pieces = return Forbidden
251+ | any (unsafe . piecePretty) pieces = return Forbidden
229− | any T.null $ safeInit pieces =
252+ | any (T.null . piecePretty) $ safeInit pieces =
230− return $ Redirect $ filterButLast (not . T.null) pieces
253+ return $ Redirect (filterButLast (not . T.null . piecePretty) pieces) Nothing
231 | otherwise = do
254 | otherwise = do
232− let fp = pathFromPieces prefix pieces
233 let (isFile, isFolder) =
255 let (isFile, isFolder) =
234 case () of
256 case () of
236 | null pieces -> (True, True)
258 | null pieces -> (True, True)
237− | T.null (last pieces) -> (False, True)
259+ | T.null $ piecePretty (last pieces) -> (False, True)
238 | otherwise -> (True, False)
260 | otherwise -> (True, False)
240− if not isFile then uncached fp isFile isFolder
262+ fl <- fileLookup pieces
241− else
263+ case (fl, isFile) of
242− case cache of
264+ (Nothing, _) -> return NotFound
243− ETag ioLookup -> do
265+ (Just (Right file), True) -> handleCache file
244− -- No support for If-Match
266+ (Just Right{}, False) -> return $ Redirect (init pieces) Nothing
245− let mlastEtag = lookup "If-None-Match" (W.requestHeaders req)
267+ (Just (Left folder@(Folder _ contents)), _) -> do
246− metag <- ioLookup fp
268+ case checkIndices $ map fileName $ rights contents of
247− case debug (metag, mlastEtag) of
269+ Just index -> return $ Redirect (setLast pieces $ Piece (T.unpack index) index) Nothing
248− (Just hash, Just lastHash) | hash == lastHash -> return NotModified
270+ Nothing ->
249− _ -> trace "ETAG: no cache match" uncached fp isFile isFolder
271+ if isFolder
250− Forever isStaticFile ->
272+ then return $ DirectoryResponse folder
251− if isStaticFile fp (S8.drop 1 $ W.rawQueryString req) &&
273+ else return $ Redirect (pieces ++ [Piece "" ""]) Nothing
252− (isJust $ lookup "If-Modified-Since" (W.requestHeaders req)) &&
253− (isNothing $ lookup "If-Unmodified-Since" (W.requestHeaders req))
254− then return NotModified
255− else trace "Static: no cache match" uncached fp isFile isFolder
256− NoCache -> trace "NoCache" uncached fp isFile isFolder
260− uncached fp isFile isFolder = do
275+ headers = W.requestHeaders req
261− fe <- doesFileExist $ stripTrailingSlash fp
276+ queryString = W.queryString req
262− case (fe, isFile) of
277+
278+ -- HTTP caching has a cache control header that you can set an expire time for a resource.
279+ -- Max-Age is easiest because it is a simple number
280+ -- a cache-control asset will only be downloaded once (if the browser maintains its cache)
281+ -- and the server will never be contacted for the resource again (until it expires)
283+ -- A second caching mechanism is ETag and last-modified
284+ -- this form of caching is not as good as the static- the browser can avoid downloading the file, but it always need to send a request with the etag value or the last-modified value to the server to see if its copy is up to date
286+ -- We should set a cache control and one of ETag or last-modifed whenever possible
288+ -- In a Yesod web application we can append an etag parameter to static assets.
289+ -- This signals that both a max-age and ETag header should be set
290+ -- if there is no etag parameter
291+ -- * don't set the max-age
292+ -- * set ETag or last-modified
293+ -- * ETag must be calculated ahead of time.
294+ -- * last-modified is just the file mtime.
295+ handleCache file = do
296+ let mGetHash = fileGetHash file
297+ let etagParam = lookup "etag" queryString
299+ case (etagParam, mGetHash) of
300+ (Just mEtag, Just getHash) -> do
302+ if isJust mEtag && hash == fromJust mEtag
263− (True, True) -> return $ FileResponse fp
303+ then return $ FileResponse file $ ("ETag", hash):cacheControl
264− (True, False) -> return $ Redirect $ init pieces
304+ else return $ Redirect pieces (Just hash)
265− (False, _) -> do
305+ -- a file used to have an etag parameter, but no longer does
266− de <- doesDirectoryExist fp
306+ (Just _, Nothing) -> return $ Redirect pieces Nothing
309+ case (lookup "if-none-match" headers, mGetHash) of
311+ (Just lastHash, Just getHash) -> do
268− then return NotFound
314+ then return NotModified
269− else do
315+ else return $ FileResponse file $ [("ETag", hash)]
270− x <- checkIndices fp indices
316+ _ ->
271− case x of
317+ case (lookup "if-modified-since" headers >>= parseModifiedDate, fileGetModified file) of
272− Just index -> return $ Redirect $ setLast pieces (T.pack index)
318+ -- last-modified
273− Nothing ->
319+ (Just lastSent, Just modified) -> do
274− if isFolder
320+ if lastSent >= modified
275− then return $ DirectoryResponse fp
321+ then return NotModified
276− else return $ Redirect $ pieces ++ [""]
322+ else respond file
277−
323+ _ -> respond file
326+ mhash <- maybe (return Nothing) (fmap Just) $ fileGetHash file
327+ return $ FileResponse file $
329+ Just h -> [("ETag", h)]
332+ setLast :: Pieces -> Piece -> Pieces
278 setLast [] x = [x]
333 setLast [] x = [x]
279− setLast [""] x = [x]
334+ setLast [Piece "" ""] x = [x]
280 setLast (a:b) x = a : setLast b x
335 setLast (a:b) x = a : setLast b x
281− checkIndices _ [] = return Nothing
282− checkIndices fp (i:is) = do
283− let fp' = fp ++ '/' : i
284− fe <- doesFileExist fp'
286− then return $ Just i
287− else checkIndices fp is
289−type Listing = (Pieces -> FilePath -> IO L.ByteString)
337+ checkIndices :: [T.Text] -> Maybe T.Text
338+ checkIndices contents = find (flip elem indices) contents
291−data StaticDirListing = ListingForbidden | StaticDirListing {
340+ cacheControl = case ccInt of
292− ssListing :: Listing
341+ Nothing -> []
293− , ssIndices :: [FilePath]
342+ Just i -> [("Cache-Control", S8.append "max-age=" $ S8.pack $ show i)]
347+ MaxAgeSeconds i -> Just i
348+ MaxAgeForever -> Just oneYear
350+ oneYear = 60 * 60 * 24 * 365
296−defaultDirListing :: StaticDirListing
352+type Listing = (Pieces -> Folder -> IO L.ByteString)
297−defaultDirListing = StaticDirListing defaultListing []
299−-- IO is for development mode
300−type CheckHashParam = (FilePath -> S8.ByteString -> Bool)
301−data CacheSettings = NoCache | Forever CheckHashParam | ETag (FilePath -> IO (Maybe S8.ByteString))
303−oneYear :: Int
355+type FileLookup = Maybe (Either Folder File)
304−oneYear = 60 * 60 * 24 * 365
356+
358+ { folderName :: T.Text
359+ , folderContents :: [Either Folder File]
363+ { fileGetSize :: Int
364+ , fileToResponse :: H.Status -> H.ResponseHeaders -> W.Response
365+ , fileName :: T.Text
366+ , fileGetHash :: Maybe (IO ByteString)
367+ , fileGetModified :: Maybe EpochTime
370+parseModifiedDate :: ByteString -> Maybe EpochTime
372+ fmap (fromIntegral . fromEnum . utcTimeToPOSIXSeconds) . parseTime defaultTimeLocale format . S8.unpack
374+ format = "%a, %d %b %Y %X %Z"
306data StaticSettings = StaticSettings
376data StaticSettings = StaticSettings
307− { ssFolder :: FilePath
377+ { ssFolder :: Pieces -> IO FileLookup
308− , ssMkRedirect :: Pieces -> ByteString -> S8.ByteString
378+ , ssMkRedirect :: Pieces -> ByteString -> ByteString
309− , ssGetMimeType :: FilePath -> IO MimeType
379+ , ssGetMimeType :: File -> IO MimeType
310− , ssDirListing :: StaticDirListing
380+ , ssListing :: Maybe Listing
311− , ssCacheSettings :: CacheSettings
381+ , ssIndices :: [T.Text] -- index.html
382+ , ssMaxAge :: MaxAge
385+data MaxAge = NoMaxAge | MaxAgeSeconds Int | MaxAgeForever
314defaultMkRedirect :: Pieces -> ByteString -> S8.ByteString
387defaultMkRedirect :: Pieces -> ByteString -> S8.ByteString
315−defaultMkRedirect pieces newPath =
388+defaultMkRedirect pieces newPath
316− let relDir = TE.encodeUtf8 (relativeDirFromPieces pieces) in
389+ | S8.null newPath || S8.null relDir ||
317− S8.append relDir (if (S8.last relDir) == '/' && (S8.head newPath) == '/'
390+ S8.last relDir /= '/' || S8.head newPath /= '/' =
391+ relDir `S8.append` newPath
318− then S8.tail newPath
392+ | otherwise = relDir `S8.append` S8.tail newPath
319− else newPath)
393+ where
320−
394+ relDir = TE.encodeUtf8 (relativeDirFromPieces pieces)
321−defaultStaticSettings :: CacheSettings -> StaticSettings
395+
322−defaultStaticSettings isStaticFile = StaticSettings { ssFolder = "static"
396+defaultWebAppSettings :: StaticSettings
397+defaultWebAppSettings = StaticSettings
398+ { ssFolder = fileSystemLookup "static"
323− , ssMkRedirect = defaultMkRedirect
399+ , ssMkRedirect = defaultMkRedirect
324− , ssGetMimeType = return . defaultMimeTypeByExt
400+ , ssGetMimeType = return . defaultMimeTypeByExt . T.unpack . fileName
325− , ssDirListing = defaultDirListing
401+ , ssMaxAge = MaxAgeForever
326− , ssCacheSettings = isStaticFile
402+ , ssListing = Nothing
328−defaultPublicSettings :: CacheSettings -> StaticSettings
405+
329−defaultPublicSettings etags = StaticSettings { ssFolder = "public"
406+defaultFileServerSettings :: StaticSettings
407+defaultFileServerSettings = StaticSettings
408+ { ssFolder = fileSystemLookup "static"
330− , ssMkRedirect = defaultMkRedirect
409+ , ssMkRedirect = defaultMkRedirect
331− , ssGetMimeType = return . defaultMimeTypeByExt
410+ , ssGetMimeType = return . defaultMimeTypeByExt . T.unpack . fileName
332− , ssDirListing = ListingForbidden
411+ , ssMaxAge = MaxAgeSeconds $ 60 * 60
333− , ssCacheSettings = etags
412+ , ssListing = Just defaultListing
413+ , ssIndices = ["index.html", "index.htm"]
416+fileHelper :: FilePath -> T.Text -> IO File
417+fileHelper fp name = do
418+ fs <- getFileStatus fp
420+ { fileGetSize = fromIntegral $ fileSize fs
421+ , fileToResponse = \s h -> W.ResponseFile s h fp Nothing
423+ , fileGetHash = Just $ do
424+ -- FIXME replace lazy IO with enumerators
425+ -- FIXME let's use a dictionary to cache these values?
428+ , fileGetModified = Just $ modificationTime fs
431+fileSystemLookup :: FilePath -> Pieces -> IO FileLookup
432+fileSystemLookup prefix pieces = do
433+ let fp = pathFromPieces prefix pieces
434+ fe <- doesFileExist fp
436+ then fmap (Just . Right) $ fileHelper fp (piecePretty $ last pieces)
438+ de <- doesDirectoryExist fp
441+ let isVisible ('.':_) = return False
442+ isVisible "" = return False
443+ isVisible _ = return True
444+ entries <- getDirectoryContents fp >>= filterM isVisible >>= (mapM $ \name -> do
445+ let name' = T.pack $ fixPathName name
446+ let fp' = fp ++ '/' : name
447+ fe' <- doesFileExist fp'
449+ then fmap Right $ fileHelper fp' name'
450+ else return $ Left $ Folder name' [])
451+ return $ Just $ Left $ Folder (error "413") entries
454+type Embedded = Map.Map T.Text EmbeddedEntry
456+data EmbeddedEntry = EEFile S8.ByteString | EEFolder Embedded
458+embeddedLookup :: Embedded -> Pieces -> IO FileLookup
459+embeddedLookup root pieces =
460+ return $ elookup "<root>" (map piecePretty pieces) root
462+ elookup :: T.Text -> [T.Text] -> Embedded -> FileLookup
463+ elookup p [] x = Just $ Left $ Folder p $ map toEntry $ Map.toList x
464+ elookup p [""] x = elookup p [] x
465+ elookup _ (p:ps) x =
466+ case Map.lookup p x of
470+ [] -> Just $ Right $ bsToFile p f
472+ Just (EEFolder y) -> elookup p ps y
474+toEntry :: (T.Text, EmbeddedEntry) -> Either Folder File
475+toEntry (name, EEFolder{}) = Left $ Folder name []
476+toEntry (name, EEFile bs) = Right $ File
477+ { fileGetSize = S8.length bs
478+ , fileToResponse = \s h -> W.ResponseBuilder s h $ fromByteString bs
480+ , fileGetHash = Just $ return $ runHash bs
481+ , fileGetModified = Nothing
484+toEmbedded :: [(FilePath, S8.ByteString)] -> Embedded
488+ texts = map (\(x, y) -> (filter (not . T.null) $ toPieces x, y)) fps
489+ toPiece fp = T.pack $ fixPathName fp
492+ let (y, z) = break (== '/') x
493+ in toPiece y : toPieces (drop 1 z)
494+ go :: [([T.Text], S8.ByteString)] -> Embedded
496+ Map.fromList $ map (second go') hoisted
498+ next = map (\(x, y) -> (head x, (tail x, y))) orig
499+ grouped :: [[(T.Text, ([T.Text], S8.ByteString))]]
500+ grouped = groupBy ((==) `on` fst) $ sortBy (comparing fst) next
501+ hoisted :: [(T.Text, [([T.Text], S8.ByteString)])]
502+ hoisted = map (fst . head &&& map snd) grouped
503+ go' :: [([T.Text], S8.ByteString)] -> EmbeddedEntry
504+ go' [([], content)] = EEFile content
505+ go' x = EEFolder $ go $ filter (\y -> not $ null $ fst y) x
507+bsToFile :: T.Text -> S8.ByteString -> File
508+bsToFile name bs = File
509+ { fileGetSize = S8.length bs
510+ , fileToResponse = \s h -> W.ResponseBuilder s h $ fromByteString bs
512+ , fileGetHash = Just $ return $ runHash bs
513+ , fileGetModified = Nothing
516+runHash :: S8.ByteString -> S8.ByteString
517+runHash = B64.encode . MD5.hash
519+runHashL :: L.ByteString -> ByteString
520+runHashL = B64.encode . MD5.hashlazy
337staticApp :: StaticSettings -> W.Application
522staticApp :: StaticSettings -> W.Application
338−staticApp set req = do
523+staticApp set req =
339− let pieces = W.pathInfo req
524+ staticAppPieces set (map toPiece $ W.pathInfo req) req
340− staticAppPieces set pieces req
525+ where
528+ , pieceRaw = unfixPathName $ T.unpack t
342status304, statusNotModified :: H.Status
531status304, statusNotModified :: H.Status
343status304 = H.Status 304 "Not Modified"
532status304 = H.Status 304 "Not Modified"
344statusNotModified = status304
533statusNotModified = status304
535+-- alist helper functions
536+replace :: Eq a => a -> b -> [(a, b)] -> [(a, b)]
537+replace k v [] = [(k,v)]
538+replace k v (x:xs) | fst x == k = (k,v):xs
539+ | otherwise = x:replace k v xs
541+remove :: Eq a => a -> [(a, b)] -> [(a, b)]
543+remove k (x:xs) | fst x == k = xs
544+ | otherwise = x:remove k xs
346staticAppPieces :: StaticSettings -> Pieces -> W.Application
547staticAppPieces :: StaticSettings -> Pieces -> W.Application
347staticAppPieces _ _ req
548staticAppPieces _ _ req
348 | W.requestMethod req /= "GET" = return $ W.responseLBS
549 | W.requestMethod req /= "GET" = return $ W.responseLBS
349 H.status405
550 H.status405
350 [("Content-Type", "text/plain")]
551 [("Content-Type", "text/plain")]
351 "Only GET is supported"
552 "Only GET is supported"
352−staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
553+staticAppPieces ss pieces req = liftIO $ do
353− let cache = ssCacheSettings ss
554+ let indices = ssIndices ss
354− let indices = case ssDirListing ss of
555+ case checkSpecialDirListing pieces of
355− StaticDirListing _ is -> is
556+ Just res -> response res
356− ListingForbidden -> []
357− cp <- checkPieces (ssFolder ss) indices pieces cache req
557+ Nothing -> checkPieces (ssFolder ss) indices pieces req (ssMaxAge ss) >>= response
358− case cp of
559+ response cp = case cp of
359− FileResponse fp -> do
560+ FileResponse file ch -> do
360− mimetype <- (ssGetMimeType ss) fp
561+ mimetype <- ssGetMimeType ss file
361− filesize <- fileSize `fmap` getFileStatus fp
562+ let filesize = fileGetSize file
362− ch <- setCacheHeaders cache fp
363− return $ W.ResponseFile H.status200
364− ( [ ("Content-Type", mimetype)
563+ let headers = ("Content-Type", mimetype)
365− , ("Content-Length", S8.pack $ show filesize)
564+ : ("Content-Length", S8.pack $ show filesize)
366− ] ++ ch ) fp Nothing
565+ : ch
566+ return $ fileToResponse file H.status200 headers
367 NotModified ->
567 NotModified ->
368 return $ W.responseLBS statusNotModified
568 return $ W.responseLBS statusNotModified
369 [ ("Content-Type", "text/plain")
569 [ ("Content-Type", "text/plain")
370 ] "Not Modified"
570 ] "Not Modified"
371 DirectoryResponse fp ->
571 DirectoryResponse fp ->
372− case ssDirListing ss of
572+ case ssListing ss of
373− StaticDirListing f _ -> do
573+ (Just f) -> do
374 lbs <- f pieces fp
574 lbs <- f pieces fp
375 return $ W.responseLBS H.status200
575 return $ W.responseLBS H.status200
376 [ ("Content-Type", "text/html; charset=utf-8")
576 [ ("Content-Type", "text/html; charset=utf-8")
378− ListingForbidden -> return $ W.responseLBS H.status403
578+ Nothing -> return $ W.responseLBS H.status403
379 [ ("Content-Type", "text/plain")
579 [ ("Content-Type", "text/plain")
380 ] "Directory listings disabled"
580 ] "Directory listings disabled"
381 SendContent mt lbs -> do
581 SendContent mt lbs -> do
@@ -384,18 +584,16 @@ staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
384 [ ("Content-Type", mt)
584 [ ("Content-Type", mt)
385 -- TODO: set Content-Length
585 -- TODO: set Content-Length
387− Redirect pieces' -> do
588+ Redirect pieces' mHash -> do
388− let loc = (ssMkRedirect ss) pieces' $ toByteString (H.encodePathSegments pieces')
589+ let loc = (ssMkRedirect ss) pieces' $ toByteString (H.encodePathSegments $ map piecePretty pieces')
389−
590+ let qString = case mHash of
390− let loc' =
591+ Just hash -> replace "etag" (Just hash) (W.queryString req)
391− -- relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces) -- last piece is not a dir
592+ Nothing -> remove "etag" (W.queryString req)
392− -- (ssMkRedirect ss) pieces' $ encodePathInfo pieces' []
593+
394− foldr mappend (H.encodePathSegments pieces') -- FIXME use Text
395− $ map (const $ copyByteString "../") $ drop 1 pieces
396 return $ W.responseLBS H.status301
594 return $ W.responseLBS H.status301
397 [ ("Content-Type", "text/plain")
595 [ ("Content-Type", "text/plain")
398− , ("Location", loc)
596+ , ("Location", S8.append loc $ H.renderQuery True qString)
399 ] "Redirect"
597 ] "Redirect"
400 Forbidden -> return $ W.responseLBS H.status403
598 Forbidden -> return $ W.responseLBS H.status403
401 [ ("Content-Type", "text/plain")
599 [ ("Content-Type", "text/plain")
@@ -403,19 +601,6 @@ staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
403 NotFound -> return $ W.responseLBS H.status404
601 NotFound -> return $ W.responseLBS H.status404
404 [ ("Content-Type", "text/plain")
602 [ ("Content-Type", "text/plain")
405 ] "File not found"
603 ] "File not found"
407− -- expires header: formatTime "%a, %d-%b-%Y %X GMT"
408− setCacheHeaders :: CacheSettings -> FilePath -> IO H.ResponseHeaders
409− setCacheHeaders (Forever isStaticFile) fp = return $
410− if isStaticFile fp (S8.drop 1 $ W.rawQueryString req)
411− then [("Cache-Control", S8.append "max-age=" $ S8.pack $ show oneYear)]
413− setCacheHeaders NoCache _ = return []
414− setCacheHeaders (ETag ioLookup) fp = do
416− return $ case etag of
417− Just hash -> [("ETag", hash)]
421The problem is that the System.Directory functions are a lie: they
606The problem is that the System.Directory functions are a lie: they
@@ -443,18 +628,17 @@ unfixPathName = S8.unpack . TE.encodeUtf8 . T.pack
444-- Code below taken from Happstack: http://patch-tag.com/r/mae/happstack/snapshot/current/content/pretty/happstack-server/src/Happstack/Server/FileServe/BuildingBlocks.hs
629-- Code below taken from Happstack: http://patch-tag.com/r/mae/happstack/snapshot/current/content/pretty/happstack-server/src/Happstack/Server/FileServe/BuildingBlocks.hs
445defaultListing :: Listing
630defaultListing :: Listing
446−defaultListing pieces localPath = do
631+defaultListing pieces (Folder _ contents) = do
447− fps <- getDirectoryContents localPath
448− fps' <- mapM (getMetaData localPath) fps
449− let isTop = null pieces || pieces == [""]
632+ let isTop = null pieces || pieces == [Piece "" ""]
450− let fps'' = if isTop then fps' else Just (FolderMetaData "..") : fps'
633+ let fps'' :: [Either Folder File]
634+ fps'' = (if isTop then id else (Left (Folder ".." []) :)) contents
451 return $ HU.renderHtml
635 return $ HU.renderHtml
452 $ H.html $ do
636 $ H.html $ do
453 H.head $ do
637 H.head $ do
454− let title = T.unpack $ T.intercalate "/" pieces
638+ let title = T.unpack $ T.intercalate "/" $ map piecePretty pieces
455 let title' = if null title then "root folder" else title
639 let title' = if null title then "root folder" else title
456− H.title $ H.string title'
640+ H.title $ H.toHtml title'
457− H.style $ H.string $ unlines [ "table { margin: 0 auto; width: 760px; border-collapse: collapse; font-family: 'sans-serif'; }"
641+ H.style $ H.toHtml $ unlines [ "table { margin: 0 auto; width: 760px; border-collapse: collapse; font-family: 'sans-serif'; }"
458 , "table, th, td { border: 1px solid #353948; }"
642 , "table, th, td { border: 1px solid #353948; }"
459 , "td.size { text-align: right; font-size: 0.7em; width: 50px }"
643 , "td.size { text-align: right; font-size: 0.7em; width: 50px }"
460 , "td.date { text-align: right; font-size: 0.7em; width: 130px }"
644 , "td.date { text-align: right; font-size: 0.7em; width: 130px }"
@@ -469,20 +653,20 @@ defaultListing pieces localPath = do
469 , "a { text-decoration: none }"
653 , "a { text-decoration: none }"
471 H.body $ do
655 H.body $ do
472− H.h1 $ showFolder $ map T.unpack $ filter (not . T.null) pieces
656+ H.h1 $ showFolder $ map piecePretty $ filter (not . T.null . piecePretty) pieces
473− renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
657+ renderDirectoryContentsTable haskellSrc folderSrc fps''
475 image x = T.unpack $ T.concat [(relativeDirFromPieces pieces), ".hidden/", x, ".png"]
659 image x = T.unpack $ T.concat [(relativeDirFromPieces pieces), ".hidden/", x, ".png"]
476 folderSrc = image "folder"
660 folderSrc = image "folder"
477 haskellSrc = image "haskell"
661 haskellSrc = image "haskell"
478 showName "" = "root"
662 showName "" = "root"
479 showName x = x
663 showName x = x
480− showFolder [] = H.string "error: Unexpected showFolder []"
664+ showFolder [] = "/"
481− showFolder [x] = H.string $ showName x
665+ showFolder [x] = H.toHtml $ showName x
482 showFolder (x:xs) = do
666 showFolder (x:xs) = do
483− let href = concat $ replicate (length xs) "../"
667+ let href = concat $ replicate (length xs) "../" :: String
484− H.a ! A.href (H.stringValue href) $ H.string $ showName x
668+ H.a ! A.href (H.toValue href) $ H.toHtml $ showName x
485− H.string " / "
669+ () <- " / "
486 showFolder xs
670 showFolder xs
488-- | a function to generate an HTML table showing the contents of a directory on the disk
672-- | a function to generate an HTML table showing the contents of a directory on the disk
@@ -495,36 +679,41 @@ defaultListing pieces localPath = do
495-- see also: 'getMetaData', 'renderDirectoryContents'
679-- see also: 'getMetaData', 'renderDirectoryContents'
496renderDirectoryContentsTable :: String
680renderDirectoryContentsTable :: String
497 -> String
681 -> String
498− -> [MetaData] -- ^ list of files+meta data, see 'getMetaData'
682+ -> [Either Folder File]
499 -> H.Html
683 -> H.Html
500renderDirectoryContentsTable haskellSrc folderSrc fps =
684renderDirectoryContentsTable haskellSrc folderSrc fps =
501− H.table $ do H.thead $ do H.th ! (A.class_ $ H.stringValue "first") $ H.img ! (A.src $ H.stringValue haskellSrc)
685+ H.table $ do H.thead $ do H.th ! (A.class_ "first") $ H.img ! (A.src $ H.toValue haskellSrc)
502− H.th $ H.string "Name"
686+ H.th "Name"
503− H.th $ H.string "Modified"
687+ H.th "Modified"
504− H.th $ H.string "Size"
688+ H.th "Size"
505 H.tbody $ mapM_ mkRow (zip (sortBy sortMD fps) $ cycle [False, True])
689 H.tbody $ mapM_ mkRow (zip (sortBy sortMD fps) $ cycle [False, True])
507− sortMD FolderMetaData{} FileMetaData{} = LT
691+ sortMD :: Either Folder File -> Either Folder File -> Ordering
508− sortMD FileMetaData{} FolderMetaData{} = GT
692+ sortMD Left{} Right{} = LT
509− sortMD x y = mdName x `compare` mdName y
693+ sortMD Right{} Left{} = GT
694+ sortMD (Left a) (Left b) = compare (folderName a) (folderName b)
695+ sortMD (Right a) (Right b) = compare (fileName a) (fileName b)
510− mkRow :: (MetaData, Bool) -> H.Html
696+ mkRow :: (Either Folder File, Bool) -> H.Html
511 mkRow (md, alt) =
697 mkRow (md, alt) =
512− (if alt then (! A.class_ (H.stringValue "alt")) else id) $
698+ (if alt then (! A.class_ "alt") else id) $
513 H.tr $ do
699 H.tr $ do
514− H.td ! A.class_ (H.stringValue "first")
700+ H.td ! A.class_ "first"
515− $ if mdIsFile md
701+ $ case md of
517− else H.img ! A.src (H.stringValue folderSrc)
702+ Left{} -> H.img ! A.src (H.toValue folderSrc)
518− ! A.alt (H.stringValue "Folder")
703+ ! A.alt "Folder"
519− H.td (H.a ! A.href (H.stringValue $ mdName' md ++ if mdIsFile md then "" else "/") $ H.string $ mdName' md)
704+ Right{} -> return ()
705+ let name = either folderName fileName md
706+ let isFile = either (const False) (const True) md
707+ H.td (H.a ! A.href (H.toValue $ name `T.append` if isFile then "" else "/") $ H.toHtml name)
520− H.td ! A.class_ (H.stringValue "date") $ H.string $
708+ H.td ! A.class_ "date" $ H.toHtml $
521− if mdIsFile md
709+ case md of
710+ Right File { fileGetModified = Just t } ->
522− then formatCalendarTime defaultTimeLocale "%d-%b-%Y %X" $ mdModified md
711+ formatCalendarTime defaultTimeLocale "%d-%b-%Y %X" t
524− H.td ! A.class_ (H.stringValue "size") $ H.string $
713+ H.td ! A.class_ "size" $ H.toHtml $
525− if mdIsFile md
714+ case md of
526− then prettyShow $ mdSize md
715+ Right File { fileGetSize = s } -> prettyShow s
527− else ""
716+ Left{} -> ""
528 formatCalendarTime a b c = formatTime a b $ posixSecondsToUTCTime (realToFrac c :: POSIXTime)
717 formatCalendarTime a b c = formatTime a b $ posixSecondsToUTCTime (realToFrac c :: POSIXTime)
529 prettyShow x
718 prettyShow x
530 | x > 1024 = prettyShowK $ x `div` 1024
719 | x > 1024 = prettyShowK $ x `div` 1024
@@ -540,20 +729,7 @@ renderDirectoryContentsTable haskellSrc folderSrc fps =
540 addCommas' (a:b:c:d:e) = a : b : c : ',' : addCommas' (d : e)
729 addCommas' (a:b:c:d:e) = a : b : c : ',' : addCommas' (d : e)
541 addCommas' x = x
730 addCommas' x = x
543−mdName' :: MetaData -> FilePath
732+{-
544−mdName' = fixPathName . mdName
548− { mdName :: FilePath
549− , mdModified :: EpochTime
550− , mdSize :: FileOffset
553− { mdName :: FilePath
557mdIsFile :: MetaData -> Bool
733mdIsFile :: MetaData -> Bool
558mdIsFile FileMetaData{} = True
734mdIsFile FileMetaData{} = True
559mdIsFile FolderMetaData{} = False
735mdIsFile FolderMetaData{} = False
@@ -566,12 +742,14 @@ getMetaData _ ('.':_) = return Nothing
566getMetaData localPath fp = do
742getMetaData localPath fp = do
567 let fp' = localPath ++ '/' : fp
743 let fp' = localPath ++ '/' : fp
568 fe <- doesFileExist fp'
744 fe <- doesFileExist fp'
745+ let fpPretty = T.pack $ fixPathName fp
571 fs <- getFileStatus fp'
748 fs <- getFileStatus fp'
572 let modTime = modificationTime fs
749 let modTime = modificationTime fs
573 let count = fileSize fs
750 let count = fileSize fs
574− return $ Just $ FileMetaData fp modTime count
751+ return $ Just $ FileMetaData fpPretty (Just modTime) count
576 de <- doesDirectoryExist fp'
753 de <- doesDirectoryExist fp'
577− return $ if de then Just (FolderMetaData fp) else Nothing
754+ return $ if de then Just (FolderMetaData fpPretty) else Nothing