diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go index 716fb77e75..398a6d813b 100644 --- a/backend/internal/web/embed_on.go +++ b/backend/internal/web/embed_on.go @@ -136,7 +136,6 @@ func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool if err != nil || info.IsDir() { return false } - applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) c.File(filePath) c.Abort() return true @@ -295,7 +294,6 @@ func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool { if err != nil || info.IsDir() { return false } - applyStaticAssetCacheHeaders(c.Writer.Header(), cleanPath) c.File(filePath) c.Abort() return true diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go index b27bbfc9dc..3ca874e1e9 100644 --- a/backend/internal/web/embed_test.go +++ b/backend/internal/web/embed_test.go @@ -5,8 +5,11 @@ package web import ( "bytes" "context" + "io/fs" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -440,6 +443,37 @@ func TestFrontendServer_InvalidateCache(t *testing.T) { }) } +func TestOverrideFilesNeverReceiveImmutableCacheHeaders(t *testing.T) { + t.Parallel() + + overrideDir := t.TempDir() + cleanPath := "assets/index-AbCd1234.js" + filePath := filepath.Join(overrideDir, cleanPath) + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte("override"), 0o644)) + + t.Run("frontend_server_override", func(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil) + + server := &FrontendServer{overrideDir: overrideDir} + assert.True(t, server.tryServeOverride(c, cleanPath)) + assert.Empty(t, w.Header().Get("Cache-Control")) + }) + + t.Run("legacy_override", func(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/"+cleanPath, nil) + + assert.True(t, tryServeOverrideFile(c, overrideDir, cleanPath)) + assert.Empty(t, w.Header().Get("Cache-Control")) + }) +} + func TestFrontendServer_Middleware(t *testing.T) { t.Run("skips_api_routes", func(t *testing.T) { provider := &mockSettingsProvider{ @@ -585,6 +619,26 @@ func TestFrontendServer_Middleware(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Header().Get("Content-Type"), "image/png") + assert.Empty(t, w.Header().Get("Cache-Control")) + + entries, err := fs.ReadDir(server.distFS, "assets") + require.NoError(t, err) + fingerprintedPath := "" + for _, entry := range entries { + candidate := "assets/" + entry.Name() + if !entry.IsDir() && isFingerprintedEmbeddedAssetPath(candidate) { + fingerprintedPath = candidate + break + } + } + require.NotEmpty(t, fingerprintedPath) + + assetWriter := httptest.NewRecorder() + assetRequest := httptest.NewRequest(http.MethodGet, "/"+fingerprintedPath, nil) + router.ServeHTTP(assetWriter, assetRequest) + + assert.Equal(t, http.StatusOK, assetWriter.Code) + assert.Equal(t, staticAssetsCacheControl, assetWriter.Header().Get("Cache-Control")) }) } diff --git a/backend/internal/web/static_cache.go b/backend/internal/web/static_cache.go index 09abc8642a..e5736d1660 100644 --- a/backend/internal/web/static_cache.go +++ b/backend/internal/web/static_cache.go @@ -4,27 +4,49 @@ package web import ( "net/http" + "path" "strings" ) -// staticAssetsCacheControl matches deploy/Caddyfile for hashed frontend assets. -// Vite emits content-hashed filenames under assets/, so long-lived immutable -// caching is safe without relying on a reverse proxy. +// Vite emits content-hashed filenames under assets/, so the backend can apply +// immutable caching without relying on a reverse proxy to classify paths. const staticAssetsCacheControl = "public, max-age=31536000, immutable" -// isLongCacheStaticPath reports whether a cleaned URL path (no leading slash) -// should receive long-lived Cache-Control headers. Aligned with deploy/Caddyfile. -func isLongCacheStaticPath(cleanPath string) bool { +// isFingerprintedEmbeddedAssetPath reports whether a cleaned URL path refers to +// a Vite asset whose filename contains the default eight-character build hash. +func isFingerprintedEmbeddedAssetPath(cleanPath string) bool { cleanPath = strings.TrimPrefix(cleanPath, "/") - return strings.HasPrefix(cleanPath, "assets/") || - cleanPath == "logo.png" || - cleanPath == "favicon.ico" + if !strings.HasPrefix(cleanPath, "assets/") { + return false + } + + filename := path.Base(cleanPath) + extension := path.Ext(filename) + stem := strings.TrimSuffix(filename, extension) + const fingerprintLength = 8 + delimiterIndex := len(stem) - fingerprintLength - 1 + if extension == "" || delimiterIndex < 1 || stem[delimiterIndex] != '-' { + return false + } + + // Vite hashes use URL-safe characters and are stable for immutable caching. + fingerprint := stem[delimiterIndex+1:] + for _, char := range fingerprint { + if (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '_' || char == '-' { + continue + } + return false + } + return true } // applyStaticAssetCacheHeaders sets Cache-Control for long-cacheable static paths. // index.html / SPA routes must keep no-cache and are not handled here. func applyStaticAssetCacheHeaders(header http.Header, cleanPath string) { - if header == nil || !isLongCacheStaticPath(cleanPath) { + if header == nil || !isFingerprintedEmbeddedAssetPath(cleanPath) { return } header.Set("Cache-Control", staticAssetsCacheControl) diff --git a/backend/internal/web/static_cache_test.go b/backend/internal/web/static_cache_test.go index 130347c41e..dae683c3e3 100644 --- a/backend/internal/web/static_cache_test.go +++ b/backend/internal/web/static_cache_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestIsLongCacheStaticPath(t *testing.T) { +func TestIsFingerprintedEmbeddedAssetPath(t *testing.T) { t.Parallel() cases := []struct { @@ -17,12 +17,16 @@ func TestIsLongCacheStaticPath(t *testing.T) { path string want bool }{ - {name: "hashed_js", path: "assets/index-abc123.js", want: true}, - {name: "hashed_css", path: "assets/app-def456.css", want: true}, - {name: "nested_asset", path: "assets/vendor/chunk.js", want: true}, - {name: "leading_slash_asset", path: "/assets/index.js", want: true}, - {name: "logo", path: "logo.png", want: true}, - {name: "favicon", path: "favicon.ico", want: true}, + {name: "fingerprinted_js", path: "assets/index-AbCd1234.js", want: true}, + {name: "fingerprinted_css", path: "assets/app-a1B2c3D4.css", want: true}, + {name: "fingerprinted_url_safe_hash", path: "assets/app-aB1-2_Cd.css", want: true}, + {name: "nested_fingerprinted_asset", path: "assets/vendor/chunk-AbCd1234.js", want: true}, + {name: "leading_slash_fingerprinted_asset", path: "/assets/index-AbCd1234.js", want: true}, + {name: "unhashed_asset", path: "assets/index.js", want: false}, + {name: "short_suffix", path: "assets/index-abc123.js", want: false}, + {name: "logo", path: "logo.png", want: false}, + {name: "favicon", path: "favicon.ico", want: false}, + {name: "fingerprint_outside_assets", path: "downloads/index-AbCd1234.js", want: false}, {name: "index_html", path: "index.html", want: false}, {name: "spa_route", path: "dashboard", want: false}, {name: "assets_prefix_only", path: "assets", want: false}, @@ -33,7 +37,7 @@ func TestIsLongCacheStaticPath(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, isLongCacheStaticPath(tc.path)) + assert.Equal(t, tc.want, isFingerprintedEmbeddedAssetPath(tc.path)) }) } } @@ -41,31 +45,27 @@ func TestIsLongCacheStaticPath(t *testing.T) { func TestApplyStaticAssetCacheHeaders(t *testing.T) { t.Parallel() - t.Run("sets_immutable_cache_for_assets", func(t *testing.T) { + t.Run("sets_immutable_cache_for_fingerprinted_asset", func(t *testing.T) { t.Parallel() header := make(http.Header) - applyStaticAssetCacheHeaders(header, "assets/index-abc.js") + applyStaticAssetCacheHeaders(header, "assets/index-AbCd1234.js") assert.Equal(t, staticAssetsCacheControl, header.Get("Cache-Control")) }) - t.Run("sets_immutable_cache_for_logo", func(t *testing.T) { - t.Parallel() - header := make(http.Header) - applyStaticAssetCacheHeaders(header, "logo.png") - assert.Equal(t, staticAssetsCacheControl, header.Get("Cache-Control")) - }) - - t.Run("skips_index_html", func(t *testing.T) { - t.Parallel() - header := make(http.Header) - applyStaticAssetCacheHeaders(header, "index.html") - assert.Empty(t, header.Get("Cache-Control")) - }) + for _, path := range []string{"assets/index.js", "logo.png", "favicon.ico", "index.html"} { + path := path + t.Run("skips_"+path, func(t *testing.T) { + t.Parallel() + header := make(http.Header) + applyStaticAssetCacheHeaders(header, path) + assert.Empty(t, header.Get("Cache-Control")) + }) + } t.Run("nil_header_is_noop", func(t *testing.T) { t.Parallel() assert.NotPanics(t, func() { - applyStaticAssetCacheHeaders(nil, "assets/x.js") + applyStaticAssetCacheHeaders(nil, "assets/index-AbCd1234.js") }) }) } diff --git a/deploy/Caddyfile b/deploy/Caddyfile index b643fe9b84..7da36ac8cb 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,21 +1,5 @@ # 修改为你的域名 api.sub2api.com { - # ========================================================================= - # 静态资源长期缓存(高优先级,放在最前面) - # 带 hash 的文件可以永久缓存,浏览器和 CDN 都会缓存 - # ========================================================================= - @static { - path /assets/* - path /logo.png - path /favicon.ico - } - header @static { - Cache-Control "public, max-age=31536000, immutable" - # 移除可能干扰缓存的头 - -Pragma - -Expires - } - # ========================================================================= # TLS 安全配置 # ========================================================================= diff --git a/deploy/test-caddyfile-cache.sh b/deploy/test-caddyfile-cache.sh new file mode 100755 index 0000000000..789e48ee2e --- /dev/null +++ b/deploy/test-caddyfile-cache.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +caddyfile="$repo_root/deploy/Caddyfile" +active_config=$(sed 's/[[:space:]]*#.*$//' "$caddyfile") + +if printf '%s\n' "$active_config" | grep -Eiq 'Cache-Control.*immutable'; then + echo "Caddyfile must not force immutable caching; the backend owns asset cache policy" >&2 + exit 1 +fi + +if ! printf '%s\n' "$active_config" | grep -Eq '^[[:space:]]*reverse_proxy[[:space:]]+localhost:8080'; then + echo "Caddyfile must continue proxying all application routes to localhost:8080" >&2 + exit 1 +fi + +echo "Caddyfile preserves backend Cache-Control policy and reverse_proxy routing"