packages feed

wai-app-static 0.2.0 → 0.3.0

raw patch · 2 files changed

+457/−251 lines, 2 filesdep +Cabaldep +HUnitdep +base64-bytestringdep ~bytestringdep ~http-typesdep ~textPVP ok

version bump matches the API change (PVP)

Dependencies added: Cabal, HUnit, base64-bytestring, cryptohash, hspec, http-date, network, wai-app-static, wai-test

Dependency ranges changed: bytestring, http-types, text, transformers, wai

API changes (from Hackage documentation)

- Network.Wai.Application.Static: ETag :: (FilePath -> IO (Maybe ByteString)) -> CacheSettings
- Network.Wai.Application.Static: FileMetaData :: FilePath -> EpochTime -> FileOffset -> MetaData
- Network.Wai.Application.Static: FolderMetaData :: FilePath -> MetaData
- Network.Wai.Application.Static: Forever :: CheckHashParam -> CacheSettings
- Network.Wai.Application.Static: NoCache :: CacheSettings
- Network.Wai.Application.Static: StaticSettings :: FilePath -> (Pieces -> ByteString -> ByteString) -> (FilePath -> IO MimeType) -> StaticDirListing -> CacheSettings -> StaticSettings
- Network.Wai.Application.Static: data CacheSettings
- Network.Wai.Application.Static: data MetaData
- Network.Wai.Application.Static: defaultDirListing :: StaticDirListing
- Network.Wai.Application.Static: defaultPublicSettings :: CacheSettings -> StaticSettings
- Network.Wai.Application.Static: defaultStaticSettings :: CacheSettings -> StaticSettings
- Network.Wai.Application.Static: getMetaData :: FilePath -> FilePath -> IO (Maybe MetaData)
- Network.Wai.Application.Static: instance Show CheckPieces
- Network.Wai.Application.Static: instance Show MetaData
- Network.Wai.Application.Static: mdIsFile :: MetaData -> Bool
- Network.Wai.Application.Static: mdModified :: MetaData -> EpochTime
- Network.Wai.Application.Static: mdName :: MetaData -> FilePath
- Network.Wai.Application.Static: mdSize :: MetaData -> FileOffset
- Network.Wai.Application.Static: ssCacheSettings :: StaticSettings -> CacheSettings
- Network.Wai.Application.Static: ssDirListing :: StaticSettings -> StaticDirListing
- Network.Wai.Application.Static: staticAppPieces :: StaticSettings -> Pieces -> Application
- Network.Wai.Application.Static: unfixPathName :: FilePath -> FilePath
+ Network.Wai.Application.Static: EEFile :: ByteString -> EmbeddedEntry
+ Network.Wai.Application.Static: EEFolder :: Embedded -> EmbeddedEntry
+ Network.Wai.Application.Static: File :: Int -> (Status -> ResponseHeaders -> Response) -> FilePath -> Maybe (IO ByteString) -> Maybe EpochTime -> File
+ Network.Wai.Application.Static: FilePath :: Text -> FilePath
+ Network.Wai.Application.Static: MaxAgeForever :: MaxAge
+ Network.Wai.Application.Static: MaxAgeSeconds :: Int -> MaxAge
+ Network.Wai.Application.Static: NoMaxAge :: MaxAge
+ Network.Wai.Application.Static: data EmbeddedEntry
+ Network.Wai.Application.Static: data File
+ Network.Wai.Application.Static: data MaxAge
+ Network.Wai.Application.Static: defaultFileServerSettings :: StaticSettings
+ Network.Wai.Application.Static: defaultMkRedirect :: Pieces -> ByteString -> ByteString
+ Network.Wai.Application.Static: defaultWebAppSettings :: StaticSettings
+ Network.Wai.Application.Static: embeddedLookup :: Embedded -> Pieces -> IO FileLookup
+ Network.Wai.Application.Static: fileGetHash :: File -> Maybe (IO ByteString)
+ Network.Wai.Application.Static: fileGetModified :: File -> Maybe EpochTime
+ Network.Wai.Application.Static: fileGetSize :: File -> Int
+ Network.Wai.Application.Static: fileName :: File -> FilePath
+ Network.Wai.Application.Static: fileSystemLookup :: FilePath -> Pieces -> IO FileLookup
+ Network.Wai.Application.Static: fileToResponse :: File -> Status -> ResponseHeaders -> Response
+ Network.Wai.Application.Static: fromFilePath :: FilePath -> FilePath
+ Network.Wai.Application.Static: instance Eq FilePath
+ Network.Wai.Application.Static: instance IsString FilePath
+ Network.Wai.Application.Static: instance Ord FilePath
+ Network.Wai.Application.Static: instance Show FilePath
+ Network.Wai.Application.Static: newtype FilePath
+ Network.Wai.Application.Static: ssIndices :: StaticSettings -> [Text]
+ Network.Wai.Application.Static: ssListing :: StaticSettings -> Maybe Listing
+ Network.Wai.Application.Static: ssMaxAge :: StaticSettings -> MaxAge
+ Network.Wai.Application.Static: toEmbedded :: [(FilePath, ByteString)] -> Embedded
+ Network.Wai.Application.Static: toFilePath :: FilePath -> FilePath
+ Network.Wai.Application.Static: type Embedded = Map FilePath EmbeddedEntry
+ Network.Wai.Application.Static: unFilePath :: FilePath -> Text
- Network.Wai.Application.Static: ssFolder :: StaticSettings -> FilePath
+ Network.Wai.Application.Static: ssFolder :: StaticSettings -> Pieces -> IO FileLookup
- Network.Wai.Application.Static: ssGetMimeType :: StaticSettings -> FilePath -> IO MimeType
+ Network.Wai.Application.Static: ssGetMimeType :: StaticSettings -> File -> IO MimeType
- Network.Wai.Application.Static: takeExtensions :: FilePath -> [String]
+ Network.Wai.Application.Static: takeExtensions :: FilePath -> [FilePath]
- Network.Wai.Application.Static: type Extension = String
+ Network.Wai.Application.Static: type Extension = FilePath
- Network.Wai.Application.Static: type Listing = Pieces -> FilePath -> IO ByteString
+ Network.Wai.Application.Static: type Listing = Pieces -> Folder -> IO ByteString
- Network.Wai.Application.Static: type Pieces = [Text]
+ Network.Wai.Application.Static: type Pieces = [FilePath]

Files

Network/Wai/Application/Static.hs view
@@ -2,9 +2,21 @@ {-# LANGUAGE TemplateHaskell, CPP #-} -- | Static file serving for WAI. module Network.Wai.Application.Static-    ( -- * Generic, non-WAI code+    ( -- * WAI application+      staticApp+      -- ** Settings+    , defaultWebAppSettings+    , defaultFileServerSettings+    , StaticSettings+    , ssFolder+    , ssMkRedirect+    , ssGetMimeType+    , ssListing+    , ssIndices+    , ssMaxAge+      -- * Generic, non-WAI code       -- ** Mime types-      MimeType+    , MimeType     , defaultMimeType       -- ** Mime type by file extension     , Extension@@ -16,26 +28,28 @@       -- ** Finding files     , Pieces     , pathFromPieces-      -- ** File/folder metadata-    , MetaData (..)-    , mdIsFile-    , getMetaData       -- ** Directory listings     , Listing     , defaultListing-    , defaultDirListing-      -- * WAI application-    , staticApp-    , staticAppPieces-      -- ** Settings-    , StaticSettings (..)-    , defaultStaticSettings-    , defaultPublicSettings-    , CacheSettings (..)-      -- should be moved to common helper-    , unfixPathName+      -- ** Lookup functions+    , fileSystemLookup+    , embeddedLookup+      -- ** Embedded+    , Embedded+    , EmbeddedEntry (..)+    , toEmbedded+      -- ** Redirecting+    , defaultMkRedirect+      -- * Other data types+    , File (..)+    , FilePath (..)+    , toFilePath+    , fromFilePath+    , MaxAge (..)     ) where +import Prelude hiding (FilePath)+import qualified Prelude import qualified Network.Wai as W import qualified Network.HTTP.Types as H import Data.Map (Map)@@ -46,49 +60,56 @@ 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 System.Posix.Types (EpochTime) import Control.Monad.IO.Class (liftIO)-import Data.Maybe (catMaybes, isNothing, isJust)+import qualified Crypto.Hash.MD5 as MD5+import Control.Monad (filterM)  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 Blaze.ByteString.Builder (toByteString, fromByteString)  import Data.Time import Data.Time.Clock.POSIX import System.Locale (defaultTimeLocale) -import Data.List (sortBy) import Data.FileEmbed (embedFile) +import Data.Text (Text) 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+import Control.Arrow ((&&&), second)+import Data.List (groupBy, sortBy, find, foldl')+import Data.Function (on)+import Data.Ord (comparing)+import qualified Data.ByteString.Base64 as B64+import Data.Either (rights)+import Data.Maybe (isJust, fromJust)+import Network.HTTP.Date (parseHTTPDate, epochTimeToHTTPDate, formatHTTPDate)+import Data.String (IsString (..)) +newtype FilePath = FilePath { unFilePath :: Text }+    deriving (Ord, Eq, Show)+instance IsString FilePath where+    fromString = toFilePath++(</>) :: FilePath -> FilePath -> FilePath+(FilePath a) </> (FilePath b) = FilePath $ T.concat [a, "/", b]+ -- | A list of all possible extensions, starting from the largest.-takeExtensions :: FilePath -> [String]-takeExtensions s =-    case break (== '.') s of-        (_, '.':x) -> x : takeExtensions x-        (_, _) -> []+takeExtensions :: FilePath -> [FilePath]+takeExtensions (FilePath s) =+    case T.break (== '.') s of+        (_, "") -> []+        (_, x) -> FilePath (T.drop 1 x) : takeExtensions (FilePath $ T.drop 1 x)  type MimeType = ByteString-type Extension = String+type Extension = FilePath type MimeMap = Map Extension MimeType  defaultMimeType :: MimeType@@ -132,6 +153,7 @@   ( "pac"     , "application/x-ns-proxy-autoconfig" ),   ( "pdf"     , "application/pdf"                   ),   ( "png"     , "image/png"                         ),+  ( "bmp"     , "image/bmp"                         ),   ( "ps"      , "application/postscript"            ),   ( "qt"      , "video/quicktime"                   ),   ( "sig"     , "application/pgp-signature"         ),@@ -152,6 +174,7 @@   ( "wma"     , "audio/x-ms-wma"                    ),   ( "wmv"     , "video/x-ms-wmv"                    ),   ( "xbm"     , "image/x-xbitmap"                   ),+  ( "xhtml"   , "application/xhtml+xml"             ),   ( "xml"     , "text/xml"                          ),   ( "xpm"     , "image/x-xpixmap"                   ),   ( "xwd"     , "image/x-xwindowdump"               ),@@ -173,16 +196,16 @@ defaultMimeTypeByExt :: FilePath -> MimeType defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType -data CheckPieces-    = Redirect Pieces+data CheckPieces =+      -- | Just the etag hash or Nothing for no etag hash+      Redirect Pieces (Maybe ByteString)     | Forbidden     | NotFound-    | FileResponse FilePath+    | FileResponse File H.ResponseHeaders     | NotModified-    | DirectoryResponse FilePath+    | DirectoryResponse Folder     -- TODO: add file size     | SendContent MimeType L.ByteString-    deriving Show  safeInit  :: [a] -> [a] safeInit [] = []@@ -196,186 +219,364 @@     | otherwise = filterButLast f xs  -unsafe :: T.Text -> Bool-unsafe s | T.null s = False-         | T.head s == '.' = True-         | otherwise = T.any (== '/') s+unsafe :: FilePath -> Bool+unsafe (FilePath s)+    | T.null s = False+    | T.head s == '.' = True+    | otherwise = T.any (== '/') s +nullFilePath :: FilePath -> Bool+nullFilePath = T.null . unFilePath+ stripTrailingSlash :: FilePath -> FilePath-stripTrailingSlash "/" = ""-stripTrailingSlash "" = ""-stripTrailingSlash (x:xs) = x : stripTrailingSlash xs+stripTrailingSlash fp@(FilePath t)+    | T.null t || T.last t /= '/' = fp+    | otherwise = FilePath $ T.init t -type Pieces = [T.Text]+type Pieces = [FilePath]+ relativeDirFromPieces :: Pieces -> T.Text relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces) -- last piece is not a dir  pathFromPieces :: FilePath -> Pieces -> FilePath-pathFromPieces prefix pieces =-        concat $ prefix : map ((:) '/') (map unfixPathName $ map T.unpack pieces)+pathFromPieces = foldl' (</>) -checkPieces :: FilePath      -- ^ static file prefix-            -> [FilePath]    -- ^ List of default index files. Cannot contain slashes.-            -> Pieces        -- ^ parsed request-            -> CacheSettings+checkSpecialDirListing :: Pieces -> Maybe CheckPieces+checkSpecialDirListing [".hidden", "folder.png"]  =+    Just $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]+checkSpecialDirListing [".hidden", "haskell.png"] =+    Just $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]+checkSpecialDirListing _ =  Nothing++checkPieces :: (Pieces -> IO FileLookup) -- ^ file lookup function+            -> [FilePath]                -- ^ List of default index files. Cannot contain slashes.+            -> Pieces                    -- ^ parsed request             -> W.Request+            -> MaxAge+            -> Bool             -> 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+checkPieces fileLookup indices pieces req maxAge useHash     | any unsafe pieces = return Forbidden-    | any T.null $ safeInit pieces =-        return $ Redirect $ filterButLast (not . T.null) pieces+    | any nullFilePath $ safeInit pieces =+        return $ Redirect (filterButLast (not . nullFilePath) pieces) Nothing     | otherwise = do-        let fp = pathFromPieces prefix pieces         let (isFile, isFolder) =                 case () of                     ()                         | null pieces -> (True, True)-                        | T.null (last pieces) -> (False, True)+                        | nullFilePath (last pieces) -> (False, True)                         | otherwise -> (True, False) -        if not isFile then uncached fp isFile isFolder-           else-             case cache of-               ETag ioLookup -> do-                 -- No support for If-Match-                 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+        fl <- fileLookup pieces+        case (fl, isFile) of+            (Nothing, _) -> return NotFound+            (Just (Right file), True)  -> handleCache file+            (Just Right{}, False) -> return $ Redirect (init pieces) Nothing+            (Just (Left folder@(Folder _ contents)), _) -> do+                case checkIndices $ map fileName $ rights contents of+                    Just index -> return $ Redirect (setLast pieces index) Nothing+                    Nothing ->+                        if isFolder+                            then return $ DirectoryResponse folder+                            else return $ Redirect (pieces ++ [""]) Nothing+  where+    headers = W.requestHeaders req+    queryString = W.queryString req +    -- HTTP caching has a cache control header that you can set an expire time for a resource.+    --   Max-Age is easiest because it is a simple number+    --   a cache-control asset will only be downloaded once (if the browser maintains its cache)+    --   and the server will never be contacted for the resource again (until it expires)+    --+    -- A second caching mechanism is ETag and last-modified+    --   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+    --+    -- We should set a cache control and one of ETag or last-modifed whenever possible+    --+    -- In a Yesod web application we can append an etag parameter to static assets.+    -- This signals that both a max-age and ETag header should be set+    -- if there is no etag parameter+    -- * don't set the max-age+    -- * set ETag or last-modified+    --   * ETag must be calculated ahead of time.+    --   * last-modified is just the file mtime.+    handleCache file =+      if not useHash then lastModifiedCache file+        else do+          let mGetHash  = fileGetHash file+          let etagParam = lookup "etag" queryString -  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 ++ [""]+          case (etagParam, mGetHash) of+            (Just mEtag, Just getHash) -> do+                hash <- getHash+                if isJust mEtag && hash == fromJust mEtag+                  then return $ FileResponse file $ ("ETag", hash):cacheControl+                  else return $ Redirect pieces (Just hash)+            -- a file used to have an etag parameter, but no longer does+            (Just _, Nothing) -> return $ Redirect pieces Nothing +            _ -> +                case (lookup "if-none-match" headers, mGetHash) of+                    -- etag+                    (mLastHash, Just getHash) -> do+                        hash <- getHash+                        case mLastHash of+                          Just lastHash ->+                            if hash == lastHash+                                then return NotModified+                                else return $ FileResponse file $ [("ETag", hash)]+                          Nothing -> return $ FileResponse file $ [("ETag", hash)]+                    (_, Nothing) -> lastModifiedCache file++    lastModifiedCache file =+      case (lookup "if-modified-since" headers >>= parseHTTPDate, fileGetModified file) of+          (mLastSent, Just modified) -> do+            let mdate = epochTimeToHTTPDate modified in+              case mLastSent of+                Just lastSent ->+                  if lastSent == mdate+                      then return NotModified+                      else return $ FileResponse file $ [("last-modified", formatHTTPDate mdate)]+                Nothing -> return $ FileResponse file $ [("last-modified", formatHTTPDate mdate)]+          _ -> return $ FileResponse file []++    setLast :: Pieces -> FilePath -> 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)+    checkIndices :: [FilePath] -> Maybe FilePath+    checkIndices contents = find (flip elem indices) contents -data StaticDirListing = ListingForbidden | StaticDirListing {-    ssListing :: Listing-  , ssIndices :: [FilePath]-}+    cacheControl = case ccInt of+        Nothing -> []+        Just i  -> [("Cache-Control", S8.append "max-age=" $ S8.pack $ show i)]+      where+        ccInt =+            case maxAge of+                NoMaxAge -> Nothing+                MaxAgeSeconds i -> Just i+                MaxAgeForever -> Just oneYear+        oneYear :: Int+        oneYear = 60 * 60 * 24 * 365 -defaultDirListing :: StaticDirListing-defaultDirListing = StaticDirListing defaultListing []+type Listing = (Pieces -> Folder -> IO L.ByteString) --- IO is for development mode-type CheckHashParam = (FilePath -> S8.ByteString -> Bool)-data CacheSettings = NoCache | Forever CheckHashParam | ETag (FilePath -> IO (Maybe S8.ByteString)) -oneYear :: Int-oneYear = 60 * 60 * 24 * 365+type FileLookup = Maybe (Either Folder File) +data Folder = Folder+    { folderName :: FilePath+    , folderContents :: [Either Folder File]+    }++data File = File+    { fileGetSize :: Int+    , fileToResponse :: H.Status -> H.ResponseHeaders -> W.Response+    , fileName :: FilePath+    , fileGetHash :: Maybe (IO ByteString)+    , fileGetModified :: Maybe EpochTime+    }+ data StaticSettings = StaticSettings-    { ssFolder :: FilePath-    , ssMkRedirect :: Pieces -> ByteString -> S8.ByteString-    , ssGetMimeType :: FilePath -> IO MimeType-    , ssDirListing :: StaticDirListing-    , ssCacheSettings :: CacheSettings+    { ssFolder :: Pieces -> IO FileLookup+    , ssMkRedirect :: Pieces -> ByteString -> ByteString+    , ssGetMimeType :: File -> IO MimeType+    , ssListing :: Maybe Listing+    , ssIndices :: [T.Text] -- index.html+    , ssMaxAge :: MaxAge+    , ssUseHash :: Bool     } +data MaxAge = NoMaxAge | MaxAgeSeconds Int | MaxAgeForever+ 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)+defaultMkRedirect pieces newPath+    | S8.null newPath || S8.null relDir ||+      S8.last relDir /= '/' || S8.head newPath /= '/' =+        relDir `S8.append` newPath+    | otherwise = relDir `S8.append` S8.tail newPath+  where+    relDir = TE.encodeUtf8 (relativeDirFromPieces pieces) -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-}+defaultWebAppSettings :: StaticSettings+defaultWebAppSettings = StaticSettings+    { ssFolder = fileSystemLookup "static"+    , ssMkRedirect  = defaultMkRedirect+    , ssGetMimeType = return . defaultMimeTypeByExt . fileName+    , ssMaxAge  = MaxAgeForever+    , ssListing = Nothing+    , ssIndices = []+    , ssUseHash = True+    } +defaultFileServerSettings :: StaticSettings+defaultFileServerSettings = StaticSettings+    { ssFolder = fileSystemLookup "static"+    , ssMkRedirect = defaultMkRedirect+    , ssGetMimeType = return . defaultMimeTypeByExt . fileName+    , ssMaxAge = MaxAgeSeconds $ 60 * 60+    , ssListing = Just defaultListing+    , ssIndices = ["index.html", "index.htm"]+    , ssUseHash = False+    } +fileHelper :: FilePath -> FilePath -> IO File+fileHelper fp name = do+    fs <- getFileStatus $ fromFilePath fp+    return File+        { fileGetSize = fromIntegral $ fileSize fs+        , fileToResponse = \s h -> W.ResponseFile s h (fromFilePath fp) Nothing+        , fileName = name+        , fileGetHash = Just $ do+            -- FIXME replace lazy IO with enumerators+            -- FIXME let's use a dictionary to cache these values?+            l <- L.readFile $ fromFilePath fp+            return $ runHashL l+        , fileGetModified = Just $ modificationTime fs+        }++fileSystemLookup :: FilePath -> Pieces -> IO FileLookup+fileSystemLookup prefix pieces = do+    let fp = pathFromPieces prefix pieces+    fe <- doesFileExist $ fromFilePath fp+    if fe+        then fmap (Just . Right) $ fileHelper fp $ last pieces+        else do+            de <- doesDirectoryExist $ fromFilePath fp+            if de+                then do+                    let isVisible ('.':_) = return False+                        isVisible "" = return False+                        isVisible _ = return True+                    entries <- getDirectoryContents (fromFilePath fp) >>= filterM isVisible >>= mapM (\nameRaw -> do+                        let name = toFilePath nameRaw+                        let fp' = fp </> name+                        fe' <- doesFileExist $ fromFilePath fp'+                        if fe'+                            then fmap Right $ fileHelper fp' name+                            else return $ Left $ Folder name [])+                    return $ Just $ Left $ Folder (error "413") entries+                else return Nothing++type Embedded = Map.Map FilePath EmbeddedEntry++data EmbeddedEntry = EEFile S8.ByteString | EEFolder Embedded++embeddedLookup :: Embedded -> Pieces -> IO FileLookup+embeddedLookup root pieces =+    return $ elookup "<root>" pieces root+  where+    elookup  :: FilePath -> [FilePath] -> Embedded -> FileLookup+    elookup p [] x = Just $ Left $ Folder p $ map toEntry $ Map.toList x+    elookup p [""] x = elookup p [] x+    elookup _ (p:ps) x =+        case Map.lookup p x of+            Nothing -> Nothing+            Just (EEFile f) ->+                case ps of+                    [] -> Just $ Right $ bsToFile p f+                    _ -> Nothing+            Just (EEFolder y) -> elookup p ps y++toEntry :: (FilePath, EmbeddedEntry) -> Either Folder File+toEntry (name, EEFolder{}) = Left $ Folder name []+toEntry (name, EEFile bs) = Right $ File+    { fileGetSize = S8.length bs+    , fileToResponse = \s h -> W.ResponseBuilder s h $ fromByteString bs+    , fileName = name+    , fileGetHash = Just $ return $ runHash bs+    , fileGetModified = Nothing+    }++toEmbedded :: [(Prelude.FilePath, S8.ByteString)] -> Embedded+toEmbedded fps =+    go texts+  where+    texts = map (\(x, y) -> (filter (not . T.null . unFilePath) $ toPieces x, y)) fps+    toPieces "" = []+    toPieces x =+        let (y, z) = break (== '/') x+         in toFilePath y : toPieces (drop 1 z)+    go :: [([FilePath], S8.ByteString)] -> Embedded+    go orig =+        Map.fromList $ map (second go') hoisted+      where+        next = map (\(x, y) -> (head x, (tail x, y))) orig+        grouped :: [[(FilePath, ([FilePath], S8.ByteString))]]+        grouped = groupBy ((==) `on` fst) $ sortBy (comparing fst) next+        hoisted :: [(FilePath, [([FilePath], S8.ByteString)])]+        hoisted = map (fst . head &&& map snd) grouped+    go' :: [([FilePath], S8.ByteString)] -> EmbeddedEntry+    go' [([], content)] = EEFile content+    go' x = EEFolder $ go $ filter (\y -> not $ null $ fst y) x++bsToFile :: FilePath -> S8.ByteString -> File+bsToFile name bs = File+    { fileGetSize = S8.length bs+    , fileToResponse = \s h -> W.ResponseBuilder s h $ fromByteString bs+    , fileName = name+    , fileGetHash = Just $ return $ runHash bs+    , fileGetModified = Nothing+    }++runHash :: S8.ByteString -> S8.ByteString+runHash = B64.encode . MD5.hash++runHashL :: L.ByteString -> ByteString+runHashL = B64.encode . MD5.hashlazy+ staticApp :: StaticSettings -> W.Application-staticApp set req = do-    let pieces = W.pathInfo req-    staticAppPieces set pieces req+staticApp set req = staticAppPieces set (map FilePath $ W.pathInfo req) req  status304, statusNotModified :: H.Status status304 = H.Status 304 "Not Modified" statusNotModified = status304 +-- alist helper functions+replace :: Eq a => a -> b -> [(a, b)] -> [(a, b)]+replace k v [] = [(k,v)]+replace k v (x:xs) | fst x == k = (k,v):xs+                   | otherwise  = x:replace k v xs++remove :: Eq a => a -> [(a, b)] -> [(a, b)]+remove _ [] = []+remove k (x:xs) | fst x == k = xs+                  | otherwise  = x:remove k xs++ 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+staticAppPieces ss pieces req = liftIO $ do+    let indices = ssIndices ss+    case checkSpecialDirListing pieces of+         Just res ->  response res+         Nothing  ->  checkPieces (ssFolder ss) (map FilePath indices) pieces req (ssMaxAge ss) (ssUseHash ss) >>= response+  where+    response cp = case cp of+        FileResponse file ch -> do+            mimetype <- ssGetMimeType ss file+            let filesize = fileGetSize file+            let headers = ("Content-Type", mimetype)+                        : ("Content-Length", S8.pack $ show filesize)+                        : ch+            return $ fileToResponse file H.status200 headers         NotModified ->             return $ W.responseLBS statusNotModified                         [ ("Content-Type", "text/plain")                         ] "Not Modified"-        DirectoryResponse fp ->-            case ssDirListing ss of-                StaticDirListing f _ -> do+        DirectoryResponse fp -> do+            case ssListing ss of+                (Just 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+                Nothing -> return $ W.responseLBS H.status403                         [ ("Content-Type", "text/plain")                         ] "Directory listings disabled"         SendContent mt lbs -> do@@ -384,18 +585,16 @@                 [ ("Content-Type", mt)                   -- TODO: set Content-Length                 ] lbs-        Redirect pieces' -> do-            let loc = (ssMkRedirect ss) pieces' $ toByteString (H.encodePathSegments pieces') -            let loc' =-                    -- relativeDirFromPieces pieces = T.concat $ map (const "../") (drop 1 pieces) -- last piece is not a dir-                    -- (ssMkRedirect ss) pieces' $ encodePathInfo pieces' [] -                    toByteString $-                    foldr mappend (H.encodePathSegments pieces') -- FIXME use Text-                    $ map (const $ copyByteString "../") $ drop 1 pieces+        Redirect pieces' mHash -> do+            let loc = (ssMkRedirect ss) pieces' $ toByteString (H.encodePathSegments $ map unFilePath pieces')+            let qString = case mHash of+                  Just hash -> replace "etag" (Just hash) (W.queryString req)+                  Nothing   -> remove "etag" (W.queryString req)+             return $ W.responseLBS H.status301                 [ ("Content-Type", "text/plain")-                , ("Location", loc)+                , ("Location", S8.append loc $ H.renderQuery True qString)                 ] "Redirect"         Forbidden -> return $ W.responseLBS H.status403                         [ ("Content-Type", "text/plain")@@ -403,19 +602,6 @@         NotFound -> return $ W.responseLBS H.status404                         [ ("Content-Type", "text/plain")                         ] "File not found"-    where-      -- expires header: formatTime "%a, %d-%b-%Y %X GMT"-        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 -> []  {- The problem is that the System.Directory functions are a lie: they@@ -427,35 +613,34 @@ Millikin's system-filepath package for some stuff with work, and might consider migrating over to it for this in the future. -}-fixPathName :: FilePath -> FilePath+toFilePath :: Prelude.FilePath -> FilePath #if defined(mingw32_HOST_OS)-fixPathName = id+toFilePath = FilePath . T.pack #else-fixPathName = T.unpack . TE.decodeUtf8With TEE.lenientDecode . S8.pack+toFilePath = FilePath . TE.decodeUtf8With TEE.lenientDecode . S8.pack #endif -unfixPathName :: FilePath -> FilePath+fromFilePath :: FilePath -> Prelude.FilePath #if defined(mingw32_HOST_OS)-unfixPathName = id+fromFilePath = T.unpack . unFilePath #else-unfixPathName = S8.unpack . TE.encodeUtf8 . T.pack+fromFilePath = S8.unpack . TE.encodeUtf8 . unFilePath #endif  -- Code below taken from Happstack: http://patch-tag.com/r/mae/happstack/snapshot/current/content/pretty/happstack-server/src/Happstack/Server/FileServe/BuildingBlocks.hs defaultListing :: Listing-defaultListing pieces localPath = do-    fps <- getDirectoryContents localPath-    fps' <- mapM (getMetaData localPath) fps+defaultListing pieces (Folder _ contents) = do     let isTop = null pieces || pieces == [""]-    let fps'' = if isTop then fps' else Just (FolderMetaData "..") : fps'+    let fps'' :: [Either Folder File]+        fps'' = (if isTop then id else (Left (Folder ".." []) :)) contents     return $ HU.renderHtml            $ H.html $ do              H.head $ do-                 let title = T.unpack $ T.intercalate "/" pieces+                 let title = T.unpack $ T.intercalate "/" $ map unFilePath 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; }" +                 H.title $ H.toHtml title'+                 H.style $ H.toHtml $ 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; }"@@ -469,20 +654,20 @@                                               , "a { text-decoration: none }"                                               ]              H.body $ do-                 H.h1 $ showFolder $ map T.unpack $ filter (not . T.null) pieces-                 renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''+                 H.h1 $ showFolder $ map unFilePath $ filter (not . nullFilePath) pieces+                 renderDirectoryContentsTable haskellSrc folderSrc 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 [] = "/"+    showFolder [x] = H.toHtml $ 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 " / "+        let href = concat $ replicate (length xs) "../" :: String+        H.a ! A.href (H.toValue href) $ H.toHtml $ showName x+        " / " :: H.Html         showFolder xs  -- | a function to generate an HTML table showing the contents of a directory on the disk@@ -495,36 +680,41 @@ -- see also: 'getMetaData', 'renderDirectoryContents' renderDirectoryContentsTable :: String                              -> String-                             -> [MetaData] -- ^ list of files+meta data, see 'getMetaData'+                             -> [Either Folder File]                              -> 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.table $ do H.thead $ do H.th ! (A.class_ "first") $ H.img ! (A.src $ H.toValue haskellSrc)+                                     H.th "Name"+                                     H.th "Modified"+                                     H.th "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+      sortMD :: Either Folder File -> Either Folder File -> Ordering+      sortMD Left{} Right{} = LT+      sortMD Right{} Left{} = GT+      sortMD (Left a) (Left b) = compare (folderName a) (folderName b)+      sortMD (Right a) (Right b) = compare (fileName a) (fileName b)+      mkRow :: (Either Folder File, Bool) -> H.Html       mkRow (md, alt) =-          (if alt then (! A.class_ (H.stringValue "alt")) else id) $+          (if alt then (! A.class_ "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 ""+                   H.td ! A.class_ "first"+                        $ case md of+                            Left{} -> H.img ! A.src (H.toValue folderSrc)+                                            ! A.alt "Folder"+                            Right{} -> return ()+                   let name = either folderName fileName md+                   let isFile = either (const False) (const True) md+                   H.td (H.a ! A.href (H.toValue $ unFilePath name `T.append` if isFile then "" else "/") $ H.toHtml $ unFilePath name)+                   H.td ! A.class_ "date" $ H.toHtml $+                       case md of+                           Right File { fileGetModified = Just t } ->+                                   formatCalendarTime defaultTimeLocale "%d-%b-%Y %X" t+                           _ -> ""+                   H.td ! A.class_ "size" $ H.toHtml $+                       case md of+                           Right File { fileGetSize = s } -> prettyShow s+                           Left{} -> ""       formatCalendarTime a b c =  formatTime a b $ posixSecondsToUTCTime (realToFrac c :: POSIXTime)       prettyShow x         | x > 1024 = prettyShowK $ x `div` 1024@@ -540,20 +730,7 @@       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@@ -566,12 +743,14 @@ getMetaData localPath fp = do     let fp' = localPath ++ '/' : fp     fe <- doesFileExist fp'+    let fpPretty = T.pack $ fixPathName fp     if fe         then do             fs <- getFileStatus fp'             let modTime = modificationTime fs             let count = fileSize fs-            return $ Just $ FileMetaData fp modTime count+            return $ Just $ FileMetaData fpPretty (Just modTime) count         else do             de <- doesDirectoryExist fp'-            return $ if de then Just (FolderMetaData fp) else Nothing+            return $ if de then Just (FolderMetaData fpPretty) else Nothing+-}
wai-app-static.cabal view
@@ -1,5 +1,5 @@ name:            wai-app-static-version:         0.2.0+version:         0.3.0 license:         BSD3 license-file:    LICENSE author:          Michael Snoyman <michael@snoyman.com>@@ -15,7 +15,7 @@  Flag print     Description:   print debug info-    Default:       True+    Default:       False  library     build-depends:   base                      >= 4        && < 5@@ -32,12 +32,39 @@                    , file-embed                >= 0.0.3.1  && < 0.1                    , text                      >= 0.5      && < 1.0                    , blaze-builder             >= 0.2.1.4  && < 0.4+                   , base64-bytestring         >= 0.1      && < 0.2+                   , cryptohash                >= 0.7      && < 0.8+                   , http-date     exposed-modules: Network.Wai.Application.Static     ghc-options:     -Wall     extensions:     CPP      if flag(print)       cpp-options:  -DPRINT++test-suite runtests+    hs-source-dirs: tests+    main-is: runtests.hs+    type: exitcode-stdio-1.0++    build-depends:   base                      >= 4        && < 5+                   , hspec >= 0.6+                   , HUnit+                   , unix-compat               >= 0.2      && < 0.3+                   , time                      >= 1.1.4    && < 1.3+                   , old-locale                >= 1.0.0.2  && < 1.1+                   , http-date+                   , Cabal+                   , wai-app-static >= 0.3+                   , wai-test+                   , wai+                   , http-types+                   , network+                   , bytestring+                   , text+                   , transformers+                   -- , containers+  ghc-options:   -Wall  source-repository head   type:     git