mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 22:31:42 +08:00
Merge pull request #4303 from wp-a/fix/web-fingerprinted-asset-caching
fix(web): limit immutable caching to fingerprinted assets
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 安全配置
|
||||
# =========================================================================
|
||||
|
||||
Executable
+18
@@ -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"
|
||||
Reference in New Issue
Block a user