feat(server): negotiate static compression and fix asset SPA fallback
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>
This commit is contained in:
+2
-1
@@ -13,6 +13,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-17-initial-load-perf-u2 | feature/initial-load-perf-u2 | - | web + static-delivery | in_progress |
|
||||
| Codex | 2026-09-17-initial-load-performance-plan | feature/initial-load-performance-plan | .worktrees/feature-initial-load-performance-plan | loading-performance-audit | done |
|
||||
| Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done |
|
||||
| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done |
|
||||
@@ -124,7 +125,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | - |
|
||||
| Cursor | merging | 2026-09-17T08:08:45Z | initial-load-perf-u2(字体/压缩/静态缓存) | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "首次加载 U2:字体自托管与静态资源交付"
|
||||
status: in_progress
|
||||
created: 2026-09-17
|
||||
updated: 2026-09-17
|
||||
owner: Cursor
|
||||
branch: feature/initial-load-perf-u2
|
||||
worktree: "-"
|
||||
mutex: web + static-delivery
|
||||
---
|
||||
|
||||
# 首次加载 U2:字体自托管与静态资源交付
|
||||
|
||||
## 目标
|
||||
|
||||
登录页不再依赖 Google Fonts 阻塞 CSS;8080/8081 静态资源支持 gzip/br 协商与分层缓存;缺失 `/assets`/`/fonts` 返回 404 而非 HTML。
|
||||
|
||||
## 范围
|
||||
|
||||
- 自托管 Inter / JetBrains Mono(latin + latin-ext 可变 WOFF2)
|
||||
- 构建期预压缩 `.br` / `.gz`
|
||||
- Go `serveSPA` 压缩协商、Cache-Control、Vary、assets 404
|
||||
- 单元测试与 rebuild 后 curl/浏览器验收
|
||||
|
||||
## 非目标
|
||||
|
||||
- U1 完整 Playwright 性能套件
|
||||
- U3 拆包懒加载 / katex 入口 CSS
|
||||
- U4–U6 初始化扇出、认证、SQL
|
||||
- U7 完整重启归因(本轮仅借 rebuild 采集起点证据)
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 诊断/方案文档落盘并合入 `dev`
|
||||
2. [x] 自托管字体 + 移除外链
|
||||
3. [x] 预压缩脚本接入 build
|
||||
4. [x] Go 静态交付修复
|
||||
5. [ ] 测试与 rebuild 验收
|
||||
6. [ ] 测试报告与文档同步
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 登录页无 `fonts.googleapis.com` / `fonts.gstatic.com` 请求
|
||||
- [ ] 主 JS 响应带 `Content-Encoding: br` 或 `gzip`,体积显著下降
|
||||
- [ ] `/assets/missing.js` → 404 且非 HTML
|
||||
- [ ] HTML `Cache-Control: no-cache`;哈希资源 `immutable`
|
||||
- [ ] SSE / API 缓存行为未回归
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 方案: [../../remediation/2026-09-17-initial-load-performance-plan.md](../../remediation/2026-09-17-initial-load-performance-plan.md)
|
||||
- 审计: [../../audit/2026-09-17-initial-load-performance-audit.md](../../audit/2026-09-17-initial-load-performance-audit.md)
|
||||
- Test report: (验收后补)
|
||||
@@ -4,9 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -339,51 +336,6 @@ func (s *Server) Routes() http.Handler {
|
||||
return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux)))
|
||||
}
|
||||
|
||||
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退
|
||||
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
"web/dist",
|
||||
"/app/web/dist",
|
||||
"../web/dist",
|
||||
"../../web/dist",
|
||||
filepath.Join(s.cfg.DataDir, "web/dist"),
|
||||
filepath.Join(s.cfg.DataDir, "../web/dist"),
|
||||
}
|
||||
|
||||
var distDir string
|
||||
for _, dir := range candidates {
|
||||
if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() {
|
||||
distDir = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if distDir == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
reqPath := filepath.Clean(r.URL.Path)
|
||||
if reqPath == "/" {
|
||||
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
targetFile := filepath.Join(distDir, reqPath)
|
||||
if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() {
|
||||
http.ServeFile(w, r, targetFile)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA Fallback 默认回退 index.html
|
||||
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
|
||||
}
|
||||
|
||||
// Start 启动 HTTP 守护进程
|
||||
func (s *Server) Start() error {
|
||||
if s.rt != nil {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
func setupStaticFixture(t *testing.T) (handler http.Handler, distDir string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
// Avoid picking up the repo's real web/dist via relative candidates.
|
||||
t.Chdir(dataDir)
|
||||
distDir = filepath.Join(dataDir, "web", "dist")
|
||||
assetsDir := filepath.Join(distDir, "assets")
|
||||
fontsDir := filepath.Join(distDir, "fonts")
|
||||
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir assets: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(fontsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir fonts: %v", err)
|
||||
}
|
||||
|
||||
indexHTML := []byte("<!doctype html><html><body>spa-index</body></html>\n")
|
||||
jsBody := bytes.Repeat([]byte("console.log('payload-for-compression-test');\n"), 80)
|
||||
fontBody := []byte("woff2-fake-bytes-not-really-a-font")
|
||||
|
||||
mustWrite := func(path string, body []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
mustWrite(filepath.Join(distDir, "index.html"), indexHTML)
|
||||
mustWrite(filepath.Join(assetsDir, "index-abc.js"), jsBody)
|
||||
mustWrite(filepath.Join(fontsDir, "inter-v20-latin.woff2"), fontBody)
|
||||
mustWrite(filepath.Join(distDir, "favicon.ico"), []byte{0, 0, 1, 0})
|
||||
|
||||
// Precompressed variants (content is gzip of jsBody; br uses same bytes for test
|
||||
// identity — we only assert Content-Encoding / length / headers, not decode br).
|
||||
var gzBuf bytes.Buffer
|
||||
zw := gzip.NewWriter(&gzBuf)
|
||||
if _, err := zw.Write(jsBody); err != nil {
|
||||
t.Fatalf("gzip write: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
mustWrite(filepath.Join(assetsDir, "index-abc.js.gz"), gzBuf.Bytes())
|
||||
// Smaller fake br payload so Content-Length differs from identity/gzip.
|
||||
brBody := append([]byte("BR"), jsBody[:40]...)
|
||||
mustWrite(filepath.Join(assetsDir, "index-abc.js.br"), brBody)
|
||||
|
||||
cfg := &config.Config{
|
||||
Env: "test",
|
||||
AgentBackend: "stub",
|
||||
DataDir: dataDir,
|
||||
}
|
||||
srv := newServerWithMemoryIAM(t, cfg)
|
||||
srv.cfg.AllowInMemory = false
|
||||
return srv.Routes(), distDir
|
||||
}
|
||||
|
||||
func TestStaticAssets_CompressionNegotiation(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
|
||||
t.Run("prefers_br", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "br" {
|
||||
t.Fatalf("Content-Encoding=%q want br", got)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary missing Accept-Encoding: %q", w.Header().Get("Vary"))
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Fatalf("Content-Type=%q want javascript", ct)
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("Cache-Control=%q", w.Header().Get("Cache-Control"))
|
||||
}
|
||||
if !bytes.HasPrefix(w.Body.Bytes(), []byte("BR")) {
|
||||
t.Fatalf("body not br fixture prefix")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gzip_only", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding=%q want gzip", got)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
decoded, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gzip decode: %v", err)
|
||||
}
|
||||
if !bytes.Contains(decoded, []byte("payload-for-compression-test")) {
|
||||
t.Fatalf("decoded body missing payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gzip_q0_identity", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip;q=0")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "" {
|
||||
t.Fatalf("Content-Encoding=%q want empty", got)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("payload-for-compression-test")) {
|
||||
t.Fatalf("identity body missing payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_accept_encoding_identity", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "" {
|
||||
t.Fatalf("Content-Encoding=%q want empty", got)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaticAssets_HEADAndConditional(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=%d", w.Code)
|
||||
}
|
||||
lastMod := w.Header().Get("Last-Modified")
|
||||
if lastMod == "" {
|
||||
t.Fatal("expected Last-Modified")
|
||||
}
|
||||
|
||||
head := httptest.NewRequest(http.MethodHead, "/assets/index-abc.js", nil)
|
||||
head.Header.Set("Accept-Encoding", "gzip")
|
||||
hw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(hw, head)
|
||||
if hw.Code != http.StatusOK {
|
||||
t.Fatalf("HEAD status=%d", hw.Code)
|
||||
}
|
||||
if hw.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("HEAD Content-Encoding=%q", hw.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if hw.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD body len=%d", hw.Body.Len())
|
||||
}
|
||||
|
||||
modTime, err := http.ParseTime(lastMod)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Last-Modified: %v", err)
|
||||
}
|
||||
cond := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
cond.Header.Set("Accept-Encoding", "gzip")
|
||||
cond.Header.Set("If-Modified-Since", modTime.UTC().Format(http.TimeFormat))
|
||||
cw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(cw, cond)
|
||||
if cw.Code != http.StatusNotModified {
|
||||
// ServeContent compares with second precision; bump if flaky.
|
||||
if cw.Code != http.StatusOK {
|
||||
t.Fatalf("conditional status=%d", cw.Code)
|
||||
}
|
||||
// Retry with far-future IMS
|
||||
cond2 := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
cond2.Header.Set("Accept-Encoding", "gzip")
|
||||
cond2.Header.Set("If-Modified-Since", time.Now().UTC().Add(24*time.Hour).Format(http.TimeFormat))
|
||||
cw2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(cw2, cond2)
|
||||
if cw2.Code != http.StatusNotModified {
|
||||
t.Fatalf("conditional with future IMS status=%d", cw2.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_RangeUsesIdentity(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip, br")
|
||||
req.Header.Set("Range", "bytes=0-15")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Fatalf("status=%d want 206", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Content-Encoding"); got != "" {
|
||||
t.Fatalf("Range must use identity, got Content-Encoding=%q", got)
|
||||
}
|
||||
if w.Body.Len() != 16 {
|
||||
t.Fatalf("range body len=%d", w.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_MissingAssetIs404NotHTML(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/missing-hash.js", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "spa-index") || strings.Contains(strings.ToLower(body), "<!doctype html") {
|
||||
t.Fatalf("missing asset returned HTML fallback: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_MissingFontIs404(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_SPAFallbackAndCache(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sessions/xxx", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte("spa-index")) {
|
||||
t.Fatalf("expected index fallback, got %q", w.Body.String())
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("Cache-Control=%q want no-cache", w.Header().Get("Cache-Control"))
|
||||
}
|
||||
|
||||
root := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rw, root)
|
||||
if rw.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("root Cache-Control=%q", rw.Header().Get("Cache-Control"))
|
||||
}
|
||||
|
||||
fav := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
||||
fw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(fw, fav)
|
||||
if fw.Code != http.StatusOK {
|
||||
t.Fatalf("favicon status=%d", fw.Code)
|
||||
}
|
||||
if fw.Header().Get("Cache-Control") != "public, max-age=3600" {
|
||||
t.Fatalf("favicon Cache-Control=%q", fw.Header().Get("Cache-Control"))
|
||||
}
|
||||
|
||||
font := httptest.NewRequest(http.MethodGet, "/fonts/inter-v20-latin.woff2", nil)
|
||||
ftw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(ftw, font)
|
||||
if ftw.Code != http.StatusOK {
|
||||
t.Fatalf("font status=%d", ftw.Code)
|
||||
}
|
||||
if ftw.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("font Cache-Control=%q", ftw.Header().Get("Cache-Control"))
|
||||
}
|
||||
if ftw.Header().Get("Content-Encoding") != "" {
|
||||
t.Fatalf("woff2 should not be compressed, got %q", ftw.Header().Get("Content-Encoding"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_PathTraversal(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/../../../etc/passwd", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
// Cleaned path may become SPA fallback or 404; must not leak /etc/passwd content.
|
||||
body := w.Body.String()
|
||||
if strings.Contains(body, "root:") || strings.Contains(body, "/bin/") {
|
||||
t.Fatalf("possible path traversal leak: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets_APIUnauthenticatedStill401(t *testing.T) {
|
||||
handler, _ := setupStaticFixture(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/sessions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d want 401", w.Code)
|
||||
}
|
||||
if w.Header().Get("Cache-Control") == "public, max-age=31536000, immutable" {
|
||||
t.Fatal("API must not receive immutable cache header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAcceptEncoding(t *testing.T) {
|
||||
offers := parseAcceptEncoding("gzip;q=0.5, br, identity;q=0")
|
||||
if len(offers) != 3 {
|
||||
t.Fatalf("len=%d", len(offers))
|
||||
}
|
||||
byName := map[string]float64{}
|
||||
for _, o := range offers {
|
||||
byName[o.name] = o.q
|
||||
}
|
||||
if byName["br"] != 1.0 || byName["gzip"] != 0.5 || byName["identity"] != 0 {
|
||||
t.Fatalf("offers=%v", offers)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user