Skip to content

Commit 082a6c6

Browse files
authored
web: serve on-the-fly compressed gzip static files (#1735)
* web: serve pre-compressed gzip static files * web: compress static files on the fly instead of pre-compressed
1 parent dc2921e commit 082a6c6

File tree

1 file changed

+94
-44
lines changed
  • src/Simplex/Messaging/Server

1 file changed

+94
-44
lines changed

src/Simplex/Messaging/Server/Web.hs

Lines changed: 94 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,24 @@ module Simplex.Messaging.Server.Web
1818
timedTTLText,
1919
) where
2020

21+
import qualified Codec.Compression.GZip as GZip
2122
import Control.Logger.Simple
2223
import Control.Monad
2324
import Data.ByteString (ByteString)
24-
import Data.ByteString.Builder (byteString)
25+
import Data.ByteString.Builder (byteString, lazyByteString)
2526
import qualified Data.ByteString.Char8 as B
27+
import qualified Data.ByteString.Lazy as LB
2628
import Data.Char (toUpper)
2729
import Data.IORef (readIORef)
2830
import Data.List (isPrefixOf, isSuffixOf)
2931
import Data.Maybe (fromMaybe)
3032
import qualified Data.Text as T
3133
import Data.Text.Encoding (encodeUtf8)
34+
import Network.HPACK.Token (tokenKey)
3235
import qualified Network.HTTP.Types as N
3336
import qualified Network.HTTP2.Server as H
3437
import Network.Socket (getPeerName)
35-
import Network.Wai (Application, Request (..))
38+
import Network.Wai (Application, Request (..), responseLBS)
3639
import Network.Wai.Application.Static (StaticSettings (..))
3740
import qualified Network.Wai.Application.Static as S
3841
import qualified Network.Wai.Handler.Warp as W
@@ -43,7 +46,7 @@ import Simplex.Messaging.Server (AttachHTTP)
4346
import Simplex.Messaging.Server.CLI (simplexmqCommit)
4447
import Simplex.Messaging.Server.Information
4548
import Simplex.Messaging.Transport (simplexMQVersion)
46-
import Simplex.Messaging.Util (ifM, tshow)
49+
import Simplex.Messaging.Util (tshow)
4750
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesFileExist)
4851
import System.FilePath
4952
import UnliftIO.Concurrent (forkFinally)
@@ -71,19 +74,20 @@ data EmbeddedContent = EmbeddedContent
7174

7275
serveStaticFiles :: EmbeddedWebParams -> IO ()
7376
serveStaticFiles 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.
8588
attachStaticFiles :: 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 =
108111
warpSettings :: W.Settings
109112
warpSettings = 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

128144
generateSite :: EmbeddedContent -> ByteString -> [String] -> FilePath -> IO ()
129145
generateSite 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'.
149165
serveStaticPageH2 :: 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.
189239
serverInfoSubsts :: String -> Maybe ServerPublicInfo -> [(ByteString, Maybe ByteString)]

0 commit comments

Comments
 (0)