packages feed

spire-http-0.1.0.0: src/Spire/Http/Static.hs

-- | Static file serving and SPA fallback middleware.
--
-- Serves files from a directory when the request path matches a prefix.
-- Includes MIME type detection and directory traversal prevention.
--
-- For single-page applications, the SPA fallback layer serves @index.html@
-- when the inner service returns 404 for paths that don't look like files.
module Spire.Http.Static
  ( -- * Static file serving
    staticFilesLayer
  , StaticConfig (..)
  , defaultStaticConfig
    -- * SPA fallback
  , spaFallbackLayer
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Network.HTTP.Types (statusCode, mkStatus)
import System.Directory (canonicalizePath, doesFileExist, getFileSize)
import System.FilePath ((</>), takeExtension)
import Data.List (isPrefixOf)

import Spire (Middleware, middleware)
import Spire.Service (Service (..))
import Http.Core (Request (..), Response (..), ok, badRequest)


-- | Configuration for static file serving.
data StaticConfig = StaticConfig
  { staticPrefix :: !Text
    -- ^ URL prefix to match (e.g. @"/static"@).
  , staticDir    :: !FilePath
    -- ^ Directory to serve files from.
  } deriving (Show)


-- | Construct a 'StaticConfig' with the given prefix and directory.
defaultStaticConfig :: Text -> FilePath -> StaticConfig
defaultStaticConfig = StaticConfig


-- | Middleware that serves files from a directory for paths matching a prefix.
--
-- If the request path starts with the configured prefix, the layer strips the
-- prefix and maps the remainder to a filesystem path under the configured
-- directory. If the file exists, it is served with an appropriate Content-Type
-- header. If the file does not exist, the request passes through to the inner
-- service.
--
-- Directory traversal is prevented by rejecting any path containing @".."@.
staticFilesLayer :: StaticConfig -> Middleware IO (Request ByteString) (Response ByteString)
staticFilesLayer cfg = middleware $ \(Service inner) -> Service $ \req -> do
  let rawPath = TE.decodeUtf8Lenient (requestPathRaw req)
      prefix  = normalizePrefix (staticPrefix cfg)
  if T.isPrefixOf prefix rawPath
    then do
      let relPath = T.unpack (T.drop (T.length prefix) rawPath)
      if hasTraversal relPath
        then pure (badRequest [] "Invalid path")
        else do
          let filePath = staticDir cfg </> relPath
          -- Canonicalize both paths to resolve symlinks and ".." segments
          -- that might bypass the traversal check above.
          canonPath <- canonicalizePath filePath
          canonDir  <- canonicalizePath (staticDir cfg)
          if canonDir `isPrefixOf` canonPath
            then do
              exists <- doesFileExist canonPath
              if exists
                then serveFile canonPath
                else inner req
            else pure (badRequest [] "Invalid path")
    else inner req


-- | SPA fallback middleware.
--
-- If the inner service returns a 404 response and the request path does not
-- look like a file request (i.e. has no file extension) and does not start
-- with @"/api"@, serve @index.html@ from the given directory instead.
spaFallbackLayer :: FilePath -> Middleware IO (Request ByteString) (Response ByteString)
spaFallbackLayer dir = middleware $ \(Service inner) -> Service $ \req -> do
  resp <- inner req
  if statusCode (responseStatus resp) == 404 && isSpaRoute req
    then do
      let indexPath = dir </> "index.html"
      exists <- doesFileExist indexPath
      if exists
        then serveFile indexPath
        else pure resp
    else pure resp


-- ===================================================================
-- Internal helpers
-- ===================================================================

-- | Ensure the prefix ends with a slash for clean stripping.
normalizePrefix :: Text -> Text
normalizePrefix p
  | T.null p         = "/"
  | T.last p == '/'  = p
  | otherwise        = p <> "/"

-- | Check for directory traversal attempts.
hasTraversal :: String -> Bool
hasTraversal path = ".." `elem` splitPath path
  where
    splitPath = filter (not . null) . foldr go [[]]
    go '/' acc       = [] : acc
    go c   (x : xs)  = (c : x) : xs
    go _   []        = []

-- | Determine if a request path looks like a SPA route (not a file, not /api).
isSpaRoute :: Request ByteString -> Bool
isSpaRoute req =
  let rawPath = requestPathRaw req
  in not (hasFileExtension rawPath) && not (BS.isPrefixOf "/api" rawPath)

-- | Check if a path has a file extension.
-- Only examines the last path segment. Dotfiles (e.g. @.hidden@, @.env@)
-- are not considered to have a file extension — the dot must not be the
-- first character.
hasFileExtension :: ByteString -> Bool
hasFileExtension path =
  let segments = filter (not . BS.null) $ BS.split 0x2F path  -- split on '/'
  in case segments of
    [] -> False
    _  -> let lastSeg = last segments
              dotIdx = BS8.elemIndex '.' lastSeg
          in case dotIdx of
               Nothing -> False
               Just 0  -> False  -- dotfile like .hidden, not a file extension
               Just _  -> True

-- | Maximum file size for static file serving (50 MiB).
-- Files larger than this are rejected with 413 Payload Too Large
-- to avoid loading excessive data into memory.
maxStaticFileSize :: Integer
maxStaticFileSize = 50 * 1024 * 1024

-- | Serve a file from disk with appropriate Content-Type.
-- Rejects files larger than 'maxStaticFileSize' with 413.
serveFile :: FilePath -> IO (Response ByteString)
serveFile filePath = do
  size <- getFileSize filePath
  if size > maxStaticFileSize
    then pure (Response (mkStatus 413 "Payload Too Large") [] "File too large")
    else do
      contents <- BS.readFile filePath
      let ext  = drop 1 (takeExtension filePath)  -- drop the leading '.'
          mime = mimeType ext
      pure (ok [("Content-Type", mime)] contents)


-- | Map file extensions to MIME types.
mimeType :: String -> ByteString
mimeType ext = case ext of
  "html"  -> "text/html"
  "css"   -> "text/css"
  "js"    -> "application/javascript"
  "json"  -> "application/json"
  "png"   -> "image/png"
  "jpg"   -> "image/jpeg"
  "jpeg"  -> "image/jpeg"
  "gif"   -> "image/gif"
  "svg"   -> "image/svg+xml"
  "ico"   -> "image/x-icon"
  "woff"  -> "font/woff"
  "woff2" -> "font/woff2"
  "ttf"   -> "font/ttf"
  "txt"   -> "text/plain"
  "xml"   -> "application/xml"
  "pdf"   -> "application/pdf"
  _       -> "application/octet-stream"