Files
CaIon 8ca26d81df fix(web): stop frontend build files from exhausting the web rate limit
GLOBAL_WEB_RATE_LIMIT (120 requests per 180 s per IP) counted every file
served from the frontend build. Since provider icons became lazy chunks,
one cold session sends about 90 static requests, so a second user behind
the same IP gets 429 on route chunks and the page fails to load.

Files that exist in the build now count against a separate static
limiter (GLOBAL_STATIC_RATE_LIMIT_ENABLE, GLOBAL_STATIC_RATE_LIMIT,
GLOBAL_STATIC_RATE_LIMIT_DURATION), disabled by default. Pages and
unknown paths still count against the web limit.

The gzip middleware also recompressed every static response, about
120 ms of CPU per request for the 4.8 MB main bundle. Build files are
embedded and never change, so each one is now served from a gzip
encoding computed once per process. Pages and unknown API paths still
go through the gzip middleware.
2026-10-07 21:26:34 +08:00

110 lines
2.9 KiB
Go

package middleware
import (
"bytes"
"compress/gzip"
"errors"
"io"
"mime"
"net/http"
"path"
"strconv"
"strings"
"sync"
ginGzip "github.com/gin-contrib/gzip"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
// precompressedFile is the gzip encoding of one frontend build file.
type precompressedFile struct {
once sync.Once
body []byte
contentType string
err error
}
// ServeFrontendFiles serves files from the embedded frontend build. The build
// never changes while the process runs, so each file is gzip-compressed once
// and the bytes are reused, instead of recompressing multi-megabyte chunks on
// every request. The cache holds at most one entry per build file. Clients
// without gzip, the image types the gzip middleware skips, directories, and
// the index.html redirect keep using http.FileServer.
func ServeFrontendFiles(frontendFS static.ServeFileSystem) gin.HandlerFunc {
fileServer := http.FileServer(frontendFS)
var cache sync.Map // URL path -> *precompressedFile
return func(c *gin.Context) {
urlPath := c.Request.URL.Path
if !frontendFS.Exists("/", urlPath) {
return
}
c.Abort()
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") ||
ginGzip.DefaultExcludedExtentions.Contains(path.Ext(urlPath)) ||
strings.HasSuffix(urlPath, "/index.html") {
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
value, ok := cache.Load(urlPath)
if !ok {
value, _ = cache.LoadOrStore(urlPath, &precompressedFile{})
}
file := value.(*precompressedFile)
file.once.Do(func() {
f, err := frontendFS.Open(urlPath)
if err != nil {
file.err = err
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
file.err = err
return
}
if info.IsDir() {
file.err = errors.New("directory")
return
}
raw, err := io.ReadAll(f)
if err != nil {
file.err = err
return
}
var compressed bytes.Buffer
writer := gzip.NewWriter(&compressed)
if _, err := writer.Write(raw); err != nil {
file.err = err
return
}
if err := writer.Close(); err != nil {
file.err = err
return
}
// Match http.FileServer: extension first, then content sniffing.
file.contentType = mime.TypeByExtension(path.Ext(urlPath))
if file.contentType == "" {
file.contentType = http.DetectContentType(raw)
}
file.body = bytes.Clone(compressed.Bytes())
})
if file.err != nil {
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// Range requests get the whole body; a server may ignore Range.
header := c.Writer.Header()
header.Set("Content-Encoding", "gzip")
header.Set("Vary", "Accept-Encoding")
header.Set("Content-Type", file.contentType)
header.Set("Content-Length", strconv.Itoa(len(file.body)))
c.Writer.WriteHeader(http.StatusOK)
if c.Request.Method != http.MethodHead {
_, _ = c.Writer.Write(file.body)
}
}
}