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 }