fix(frontend): correct static cache headers

pull/5942/head
boojack 3 weeks ago
parent 4f0556e23f
commit 084f40bc9e

@ -6,6 +6,7 @@ import (
"encoding/xml"
"io/fs"
"net/http"
"path"
"strings"
"github.com/labstack/echo/v5"
@ -13,13 +14,18 @@ import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
//go:embed dist/*
var embeddedFiles embed.FS
const (
frontendHTMLCacheControl = "no-cache, no-store, must-revalidate"
frontendStaticAssetCacheControl = "public, max-age=3600"
frontendHashedAssetCacheControl = "public, max-age=3600, immutable"
)
type FrontendService struct {
Profile *profile.Profile
Store *store.Store
@ -34,24 +40,12 @@ func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendS
func (s *FrontendService) Serve(_ context.Context, e *echo.Echo) {
skipper := func(c *echo.Context) bool {
// Skip API routes.
if util.HasPrefixes(c.Path(), "/api", "/memos.api.v1", "/robots.txt", "/sitemap.xml") {
requestPath := c.Request().URL.Path
if shouldSkipFrontendStatic(requestPath) {
return true
}
// For index.html and root path, set no-cache headers to prevent browser caching
// This prevents sensitive data from being accessible via browser back button after logout
if c.Path() == "/" || c.Path() == "/index.html" {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache, no-store, must-revalidate")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return false
}
// Set Cache-Control header for static assets.
// Since Vite generates content-hashed filenames (e.g., index-BtVjejZf.js),
// we can cache aggressively but use immutable to prevent revalidation checks.
// For frequently redeployed instances, use shorter max-age (1 hour) to avoid
// serving stale assets after redeployment.
c.Response().Header().Set(echo.HeaderCacheControl, "public, max-age=3600, immutable") // 1 hour
setFrontendCacheHeaders(c, requestPath)
return false
}
@ -65,6 +59,40 @@ func (s *FrontendService) Serve(_ context.Context, e *echo.Echo) {
s.registerRoutes(e)
}
func shouldSkipFrontendStatic(requestPath string) bool {
if requestPath == "/robots.txt" || requestPath == "/sitemap.xml" || strings.HasSuffix(requestPath, "/rss.xml") {
return true
}
return hasPathPrefix(requestPath, "/api") ||
hasPathPrefix(requestPath, "/file") ||
hasPathPrefix(requestPath, "/mcp") ||
requestPath == "/memos.api.v1" ||
strings.HasPrefix(requestPath, "/memos.api.v1.")
}
func setFrontendCacheHeaders(c *echo.Context, requestPath string) {
if shouldServeFrontendHTML(requestPath) {
c.Response().Header().Set(echo.HeaderCacheControl, frontendHTMLCacheControl)
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return
}
cacheControl := frontendStaticAssetCacheControl
if strings.HasPrefix(requestPath, "/assets/") {
cacheControl = frontendHashedAssetCacheControl
}
c.Response().Header().Set(echo.HeaderCacheControl, cacheControl)
}
func shouldServeFrontendHTML(requestPath string) bool {
return requestPath == "/" || requestPath == "/index.html" || path.Ext(requestPath) == ""
}
func hasPathPrefix(requestPath, prefix string) bool {
return requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/")
}
func getFileSystem(path string) fs.FS {
sub, err := fs.Sub(embeddedFiles, path)
if err != nil {

@ -2,8 +2,10 @@ package frontend
import (
"context"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
@ -14,6 +16,87 @@ import (
teststore "github.com/usememos/memos/store/test"
)
func TestFrontendService_StaticCacheHeaders(t *testing.T) {
ctx := context.Background()
testStore := teststore.NewTestingStore(ctx, t)
e := echo.New()
NewFrontendService(&profile.Profile{}, testStore).Serve(ctx, e)
hashedAssetPath := firstEmbeddedAssetPath(t, ".js")
tests := []struct {
name string
path string
cacheControl string
pragma string
expires string
}{
{
name: "root html is not stored",
path: "/",
cacheControl: frontendHTMLCacheControl,
pragma: "no-cache",
expires: "0",
},
{
name: "index html is not stored",
path: "/index.html",
cacheControl: frontendHTMLCacheControl,
pragma: "no-cache",
expires: "0",
},
{
name: "spa fallback html is not stored",
path: "/memos/publicmemo",
cacheControl: frontendHTMLCacheControl,
pragma: "no-cache",
expires: "0",
},
{
name: "hashed asset is immutable",
path: hashedAssetPath,
cacheControl: frontendHashedAssetCacheControl,
},
{
name: "stable root asset is revalidated after max age",
path: "/logo.webp",
cacheControl: frontendStaticAssetCacheControl,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, tt.cacheControl, rec.Header().Get(echo.HeaderCacheControl))
require.Equal(t, tt.pragma, rec.Header().Get("Pragma"))
require.Equal(t, tt.expires, rec.Header().Get("Expires"))
})
}
}
func TestFrontendService_SkipsDynamicRoutes(t *testing.T) {
ctx := context.Background()
testStore := teststore.NewTestingStore(ctx, t)
e := echo.New()
NewFrontendService(&profile.Profile{}, testStore).Serve(ctx, e)
e.GET("/api/test", func(c *echo.Context) error {
return c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Empty(t, rec.Header().Get(echo.HeaderCacheControl))
}
func TestFrontendService_RobotsTXT(t *testing.T) {
ctx := context.Background()
testStore := teststore.NewTestingStore(ctx, t)
@ -91,3 +174,18 @@ func TestFrontendService_SitemapRoutesRequireInstanceURL(t *testing.T) {
require.Equal(t, http.StatusNotFound, rec.Code)
}
}
func firstEmbeddedAssetPath(t *testing.T, suffix string) string {
t.Helper()
var assetPath string
require.NoError(t, fs.WalkDir(getFileSystem("dist"), "assets", func(path string, d fs.DirEntry, err error) error {
require.NoError(t, err)
if assetPath == "" && !d.IsDir() && strings.HasSuffix(path, suffix) {
assetPath = "/" + path
}
return nil
}))
require.NotEmpty(t, assetPath)
return assetPath
}

Loading…
Cancel
Save