@@ -18,21 +18,24 @@ module Simplex.Messaging.Server.Web
1818 timedTTLText ,
1919 ) where
2020
21+ import qualified Codec.Compression.GZip as GZip
2122import Control.Logger.Simple
2223import Control.Monad
2324import Data.ByteString (ByteString )
24- import Data.ByteString.Builder (byteString )
25+ import Data.ByteString.Builder (byteString , lazyByteString )
2526import qualified Data.ByteString.Char8 as B
27+ import qualified Data.ByteString.Lazy as LB
2628import Data.Char (toUpper )
2729import Data.IORef (readIORef )
2830import Data.List (isPrefixOf , isSuffixOf )
2931import Data.Maybe (fromMaybe )
3032import qualified Data.Text as T
3133import Data.Text.Encoding (encodeUtf8 )
34+ import Network.HPACK.Token (tokenKey )
3235import qualified Network.HTTP.Types as N
3336import qualified Network.HTTP2.Server as H
3437import Network.Socket (getPeerName )
35- import Network.Wai (Application , Request (.. ))
38+ import Network.Wai (Application , Request (.. ), responseLBS )
3639import Network.Wai.Application.Static (StaticSettings (.. ))
3740import qualified Network.Wai.Application.Static as S
3841import qualified Network.Wai.Handler.Warp as W
@@ -43,7 +46,7 @@ import Simplex.Messaging.Server (AttachHTTP)
4346import Simplex.Messaging.Server.CLI (simplexmqCommit )
4447import Simplex.Messaging.Server.Information
4548import Simplex.Messaging.Transport (simplexMQVersion )
46- import Simplex.Messaging.Util (ifM , tshow )
49+ import Simplex.Messaging.Util (tshow )
4750import System.Directory (canonicalizePath , createDirectoryIfMissing , doesFileExist )
4851import System.FilePath
4952import UnliftIO.Concurrent (forkFinally )
@@ -71,19 +74,20 @@ data EmbeddedContent = EmbeddedContent
7174
7275serveStaticFiles :: EmbeddedWebParams -> IO ()
7376serveStaticFiles EmbeddedWebParams {webStaticPath, webHttpPort, webHttpsParams} = do
77+ app <- staticFiles webStaticPath
7478 forM_ webHttpPort $ \ port -> flip forkFinally (\ e -> logError $ " HTTP server crashed: " <> tshow e) $ do
7579 logInfo $ " Serving static site on port " <> tshow port
7680 W. runSettings (mkSettings port) app
7781 forM_ webHttpsParams $ \ WebHttpsParams {port, cert, key} -> flip forkFinally (\ e -> logError $ " HTTPS server crashed: " <> tshow e) $ do
7882 logInfo $ " Serving static site on port " <> tshow port <> " (TLS)"
7983 WT. runTLS (WT. tlsSettings cert key) (mkSettings port) app
8084 where
81- app = staticFiles webStaticPath
8285 mkSettings port = W. setPort port warpSettings
8386
8487-- | Prepare context and prepare HTTP handler for TLS connections that already passed TLS.handshake and ALPN check.
8588attachStaticFiles :: FilePath -> (AttachHTTP -> IO () ) -> IO ()
86- attachStaticFiles path action =
89+ attachStaticFiles path action = do
90+ app <- staticFiles path
8791 -- Initialize global internal state for http server.
8892 WI. withII warpSettings $ \ ii -> do
8993 action $ \ socket cxt -> do
@@ -94,7 +98,6 @@ attachStaticFiles path action =
9498 -- Run Warp connection handler to process HTTP requests for static files.
9599 WI. serveConnection conn ii th addr transport warpSettings app
96100 where
97- app = staticFiles path
98101 -- from warp-tls
99102 withConnection socket cxt = bracket (WT. attachConn socket cxt) (terminate . fst )
100103 -- from warp
@@ -108,8 +111,10 @@ attachStaticFiles path action =
108111warpSettings :: W. Settings
109112warpSettings = W. setGracefulShutdownTimeout (Just 1 ) W. defaultSettings
110113
111- staticFiles :: FilePath -> Application
112- staticFiles root = S. staticApp settings . changeWellKnownPath
114+ staticFiles :: FilePath -> IO Application
115+ staticFiles root = do
116+ canonRoot <- canonicalizePath root
117+ pure $ withGzipFiles canonRoot (S. staticApp settings) . changeWellKnownPath
113118 where
114119 settings = defSettings {ssListing = Nothing , ssGetMimeType = getMimeType}
115120 defSettings = S. defaultFileServerSettings root
@@ -120,10 +125,21 @@ staticFiles root = S.staticApp settings . changeWellKnownPath
120125 " .well-known" : rest ->
121126 req
122127 { pathInfo = " well-known" : rest,
123- rawPathInfo = " /well-known/ " <> B. drop pfxLen (rawPathInfo req)
128+ rawPathInfo = rewriteWellKnown (rawPathInfo req)
124129 }
125130 _ -> req
126- pfxLen = B. length " /.well-known/"
131+
132+ -- | WAI middleware that gzip-compresses static files on the fly when client accepts gzip.
133+ -- Falls through to the wrapped app for non-compressible files or when gzip is not accepted.
134+ withGzipFiles :: FilePath -> Application -> Application
135+ withGzipFiles canonRoot app req respond
136+ | acceptsGzipWAI req =
137+ resolveStaticFile canonRoot (rawPathInfo req) >>= \ case
138+ Just (file, mime) | isCompressible file -> do
139+ content <- B. readFile file
140+ respond $ responseLBS N. ok200 (staticResponseHeaders mime True ) (GZip. compress $ LB. fromStrict content)
141+ _ -> app req respond
142+ | otherwise = app req respond
127143
128144generateSite :: EmbeddedContent -> ByteString -> [String ] -> FilePath -> IO ()
129145generateSite embedded indexContent linkPages sitePath = do
@@ -147,43 +163,77 @@ generateSite embedded indexContent linkPages sitePath = do
147163-- Path traversal protection: resolved path must stay under canonicalRoot.
148164-- canonicalRoot must be pre-computed via 'canonicalizePath'.
149165serveStaticPageH2 :: FilePath -> H. Request -> (H. Response -> IO () ) -> IO Bool
150- serveStaticPageH2 canonicalRoot req sendResponse = do
151- let rawPath = fromMaybe " /" $ H. requestPath req
152- path = rewriteWellKnownH2 rawPath
153- relPath = B. unpack $ B. dropWhile (== ' /' ) path
166+ serveStaticPageH2 canonRoot req sendResponse = do
167+ let rawPath = rewriteWellKnown $ fromMaybe " /" $ H. requestPath req
168+ resolveStaticFile canonRoot rawPath >>= \ case
169+ Just (file, mime) -> do
170+ content <- B. readFile file
171+ let gz = acceptsGzipH2 req && isCompressible file
172+ body
173+ | gz = lazyByteString $ GZip. compress $ LB. fromStrict content
174+ | otherwise = byteString content
175+ sendResponse $ H. responseBuilder N. ok200 (staticResponseHeaders mime gz) body
176+ pure True
177+ Nothing -> pure False
178+
179+ -- | Resolve a static file request to a file path.
180+ -- Handles index.html fallback and path traversal protection.
181+ -- canonRoot must be pre-computed via 'canonicalizePath'.
182+ resolveStaticFile :: FilePath -> ByteString -> IO (Maybe (FilePath , ByteString ))
183+ resolveStaticFile canonRoot path = do
184+ let relPath = B. unpack $ B. dropWhile (== ' /' ) path
154185 requestedPath
155- | null relPath || relPath == " /" = canonicalRoot </> " index.html"
156- | otherwise = canonicalRoot </> relPath
157- indexPath = requestedPath </> " index.html"
158- ifM
159- (doesFileExist requestedPath)
160- (serveSafe requestedPath)
161- (ifM (doesFileExist indexPath) (serveSafe indexPath) (pure False ))
186+ | null relPath = canonRoot </> " index.html"
187+ | otherwise = canonRoot </> relPath
188+ tryResolve requestedPath
189+ >>= maybe (tryResolve (requestedPath </> " index.html" )) (pure . Just )
162190 where
163- serveSafe filePath = do
164- canonicalFile <- canonicalizePath filePath
165- if (canonicalRoot <> " / " ) `isPrefixOf` canonicalFile || canonicalRoot == canonicalFile
191+ tryResolve filePath = do
192+ exists <- doesFileExist filePath
193+ if exists
166194 then do
167- content <- B. readFile canonicalFile
168- sendResponse $ H. responseBuilder N. ok200 [(" Content-Type" , staticMimeType canonicalFile)] (byteString content)
169- pure True
170- else pure False -- path traversal attempt
171- rewriteWellKnownH2 p
172- | " /.well-known/" `B.isPrefixOf` p = " /well-known/" <> B. drop (B. length " /.well-known/" ) p
173- | otherwise = p
174- staticMimeType fp
175- | " .html" `isSuffixOf` fp = " text/html"
176- | " .css" `isSuffixOf` fp = " text/css"
177- | " .js" `isSuffixOf` fp = " application/javascript"
178- | " .svg" `isSuffixOf` fp = " image/svg+xml"
179- | " .png" `isSuffixOf` fp = " image/png"
180- | " .ico" `isSuffixOf` fp = " image/x-icon"
181- | " .json" `isSuffixOf` fp = " application/json"
182- | " apple-app-site-association" `isSuffixOf` fp = " application/json"
183- | " .woff" `isSuffixOf` fp = " font/woff"
184- | " .woff2" `isSuffixOf` fp = " font/woff2"
185- | " .ttf" `isSuffixOf` fp = " font/ttf"
186- | otherwise = " application/octet-stream"
195+ canonFile <- canonicalizePath filePath
196+ if (canonRoot <> " /" ) `isPrefixOf` canonFile || canonRoot == canonFile
197+ then pure $ Just (canonFile, staticMimeType canonFile)
198+ else pure Nothing -- path traversal attempt
199+ else pure Nothing
200+
201+ rewriteWellKnown :: ByteString -> ByteString
202+ rewriteWellKnown p
203+ | " /.well-known/" `B.isPrefixOf` p = " /well-known/" <> B. drop (B. length " /.well-known/" ) p
204+ | p == " /.well-known" = " /well-known"
205+ | otherwise = p
206+
207+ acceptsGzipH2 :: H. Request -> Bool
208+ acceptsGzipH2 req = any (\ (t, v) -> tokenKey t == " accept-encoding" && " gzip" `B.isInfixOf` v) (fst $ H. requestHeaders req)
209+
210+ acceptsGzipWAI :: Request -> Bool
211+ acceptsGzipWAI req = maybe False (" gzip" `B.isInfixOf` ) $ lookup " Accept-Encoding" (requestHeaders req)
212+
213+ isCompressible :: FilePath -> Bool
214+ isCompressible fp =
215+ any (`isSuffixOf` fp) [" .html" , " .css" , " .js" , " .svg" , " .json" ]
216+ || " apple-app-site-association" `isSuffixOf` fp
217+
218+ staticResponseHeaders :: ByteString -> Bool -> [N. Header ]
219+ staticResponseHeaders mime gz
220+ | gz = [(" Content-Type" , mime), (" Content-Encoding" , " gzip" ), (" Vary" , " Accept-Encoding" )]
221+ | otherwise = [(" Content-Type" , mime)]
222+
223+ staticMimeType :: FilePath -> ByteString
224+ staticMimeType fp
225+ | " .html" `isSuffixOf` fp = " text/html"
226+ | " .css" `isSuffixOf` fp = " text/css"
227+ | " .js" `isSuffixOf` fp = " application/javascript"
228+ | " .svg" `isSuffixOf` fp = " image/svg+xml"
229+ | " .png" `isSuffixOf` fp = " image/png"
230+ | " .ico" `isSuffixOf` fp = " image/x-icon"
231+ | " .json" `isSuffixOf` fp = " application/json"
232+ | " apple-app-site-association" `isSuffixOf` fp = " application/json"
233+ | " .woff" `isSuffixOf` fp = " font/woff"
234+ | " .woff2" `isSuffixOf` fp = " font/woff2"
235+ | " .ttf" `isSuffixOf` fp = " font/ttf"
236+ | otherwise = " application/octet-stream"
187237
188238-- | Substitutions for server information fields shared between SMP and XFTP pages.
189239serverInfoSubsts :: String -> Maybe ServerPublicInfo -> [(ByteString , Maybe ByteString )]
0 commit comments