Serve precompressed .br/.gz with Cache-Control and Vary, and return 404 for missing /assets and /fonts instead of index.html. Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
6.8 KiB
Go
283 lines
6.8 KiB
Go
package server
|
|
|
|
import (
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退。
|
|
// /assets 与 /fonts 下缺失文件返回 404(不回退 HTML),避免旧 chunk 拿到 HTML 当 JS。
|
|
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
distDir := resolveDistDir(s.cfg.DataDir)
|
|
if distDir == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
reqPath := filepath.Clean(r.URL.Path)
|
|
if reqPath == "/" || reqPath == "." {
|
|
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
|
|
return
|
|
}
|
|
|
|
// Prevent path escape outside distDir.
|
|
targetFile := filepath.Join(distDir, strings.TrimPrefix(reqPath, string(filepath.Separator)))
|
|
if !isPathInside(distDir, targetFile) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
noFallback := strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/")
|
|
if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() {
|
|
serveStaticFile(w, r, targetFile, cacheControlFor(reqPath), true)
|
|
return
|
|
}
|
|
|
|
if noFallback {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// SPA client-route fallback
|
|
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
|
|
}
|
|
|
|
func resolveDistDir(dataDir string) string {
|
|
candidates := []string{
|
|
"web/dist",
|
|
"/app/web/dist",
|
|
"../web/dist",
|
|
"../../web/dist",
|
|
filepath.Join(dataDir, "web/dist"),
|
|
filepath.Join(dataDir, "../web/dist"),
|
|
}
|
|
for _, dir := range candidates {
|
|
if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() {
|
|
return dir
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isPathInside(root, candidate string) bool {
|
|
absRoot, err1 := filepath.Abs(root)
|
|
absCand, err2 := filepath.Abs(candidate)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
rel, err := filepath.Rel(absRoot, absCand)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func cacheControlFor(reqPath string) string {
|
|
if strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") {
|
|
return "public, max-age=31536000, immutable"
|
|
}
|
|
base := filepath.Base(reqPath)
|
|
if base == "index.html" {
|
|
return "no-cache"
|
|
}
|
|
// Root non-hashed assets (favicon, etc.)
|
|
return "public, max-age=3600"
|
|
}
|
|
|
|
func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath, cacheControl string, negotiateCompress bool) {
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
|
|
fi, err := os.Stat(filePath)
|
|
if err != nil || fi.IsDir() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Range requests: serve identity only (encoded byte ranges are ambiguous).
|
|
useCompress := negotiateCompress && r.Header.Get("Range") == ""
|
|
encoding, variantPath := "", filePath
|
|
if useCompress {
|
|
encoding, variantPath = pickCompressedVariant(filePath, r.Header.Get("Accept-Encoding"))
|
|
}
|
|
|
|
// Always Vary when compression negotiation is in play for compressible types.
|
|
if negotiateCompress && isCompressiblePath(filePath) {
|
|
w.Header().Set("Vary", "Accept-Encoding")
|
|
}
|
|
|
|
openPath := filePath
|
|
if encoding != "" {
|
|
if vfi, verr := os.Stat(variantPath); verr == nil && !vfi.IsDir() {
|
|
openPath = variantPath
|
|
w.Header().Set("Content-Encoding", encoding)
|
|
if ct := contentTypeFor(filePath); ct != "" {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
} else {
|
|
encoding = ""
|
|
openPath = filePath
|
|
}
|
|
}
|
|
|
|
f, err := os.Open(openPath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Use original file modtime for conditional requests so identity/compressed stay consistent.
|
|
http.ServeContent(w, r, filepath.Base(filePath), fi.ModTime(), f)
|
|
}
|
|
|
|
func isCompressiblePath(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".js", ".css", ".html", ".svg", ".json", ".txt", ".xml", ".map":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func contentTypeFor(path string) string {
|
|
ext := filepath.Ext(path)
|
|
if ext == "" {
|
|
return ""
|
|
}
|
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
|
return ct
|
|
}
|
|
switch strings.ToLower(ext) {
|
|
case ".js":
|
|
return "text/javascript; charset=utf-8"
|
|
case ".css":
|
|
return "text/css; charset=utf-8"
|
|
case ".html":
|
|
return "text/html; charset=utf-8"
|
|
case ".svg":
|
|
return "image/svg+xml"
|
|
case ".json":
|
|
return "application/json"
|
|
case ".woff2":
|
|
return "font/woff2"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
type encodingOffer struct {
|
|
name string
|
|
q float64
|
|
}
|
|
|
|
// pickCompressedVariant selects br or gzip based on Accept-Encoding q-values.
|
|
// Returns ("", original) when identity should be used.
|
|
func pickCompressedVariant(filePath, acceptEncoding string) (encoding, variantPath string) {
|
|
if !isCompressiblePath(filePath) {
|
|
return "", filePath
|
|
}
|
|
offers := parseAcceptEncoding(acceptEncoding)
|
|
if len(offers) == 0 {
|
|
return "", filePath
|
|
}
|
|
|
|
identityQ := 1.0
|
|
identityExplicit := false
|
|
starQ := -1.0
|
|
preferred := make([]encodingOffer, 0, 2)
|
|
|
|
for _, o := range offers {
|
|
switch o.name {
|
|
case "identity":
|
|
identityQ = o.q
|
|
identityExplicit = true
|
|
case "*":
|
|
starQ = o.q
|
|
case "br", "gzip":
|
|
if o.q > 0 {
|
|
preferred = append(preferred, o)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(preferred, func(i, j int) bool {
|
|
if preferred[i].q != preferred[j].q {
|
|
return preferred[i].q > preferred[j].q
|
|
}
|
|
// Prefer br over gzip at equal q.
|
|
if preferred[i].name == "br" && preferred[j].name != "br" {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
for _, o := range preferred {
|
|
var variant string
|
|
switch o.name {
|
|
case "br":
|
|
variant = filePath + ".br"
|
|
case "gzip":
|
|
variant = filePath + ".gz"
|
|
default:
|
|
continue
|
|
}
|
|
if fi, err := os.Stat(variant); err == nil && !fi.IsDir() {
|
|
if o.q >= identityQ || !identityExplicit {
|
|
return o.name, variant
|
|
}
|
|
}
|
|
}
|
|
|
|
// If identity is forbidden (q=0) but a wildcard allows compression, already handled.
|
|
// If identity q=0 and no compressed variant, fall through to identity anyway
|
|
// (cannot invent an encoding); callers still serve the file.
|
|
_ = starQ
|
|
return "", filePath
|
|
}
|
|
|
|
func parseAcceptEncoding(header string) []encodingOffer {
|
|
header = strings.TrimSpace(header)
|
|
if header == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(header, ",")
|
|
out := make([]encodingOffer, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
name := part
|
|
q := 1.0
|
|
if semi := strings.IndexByte(part, ';'); semi >= 0 {
|
|
name = strings.TrimSpace(part[:semi])
|
|
params := strings.Split(part[semi+1:], ";")
|
|
for _, p := range params {
|
|
p = strings.TrimSpace(p)
|
|
if strings.HasPrefix(strings.ToLower(p), "q=") {
|
|
if v, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil {
|
|
q = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
if name == "" {
|
|
continue
|
|
}
|
|
out = append(out, encodingOffer{name: name, q: q})
|
|
}
|
|
return out
|
|
}
|