Skip to content

Commit 00ec352

Browse files
committed
fix: set ETag and Cache-Control headers on embedded fallback assets
Embedded assets (style.css, index.html, 404.html) served via serveEmbedded() and serveNotFound() were missing ETag, Cache-Control, and Vary headers, preventing conditional request (304) support. Both paths now emit the same caching headers as the regular disk/cache path, respecting the enable_etags config flag. Three new tests cover ETag on/off and Cache-Control max-age for the embedded path. Landing page updated to reflect full ETag coverage across all assets.
1 parent c0ee494 commit 00ec352

File tree

3 files changed

+95
-1
lines changed

3 files changed

+95
-1
lines changed

internal/handler/file.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,18 @@ func (h *FileHandler) serveEmbedded(ctx *fasthttp.RequestCtx, urlPath string) bo
383383
return false
384384
}
385385
ct := detectContentType(name, data)
386+
etag := computeETag(data)
387+
386388
ctx.Response.Header.Set("Content-Type", ct)
387389
ctx.Response.Header.Set("X-Cache", "MISS")
390+
391+
// Set ETag and Cache-Control headers for embedded assets.
392+
if h.cfg.Headers.EnableETags {
393+
ctx.Response.Header.Set("ETag", `W/"`+etag+`"`)
394+
}
395+
ctx.Response.Header.Set("Cache-Control", "public, max-age="+strconv.Itoa(h.cfg.Headers.StaticMaxAge))
396+
ctx.Response.Header.Add("Vary", "Accept-Encoding")
397+
388398
ctx.SetStatusCode(fasthttp.StatusOK)
389399
ctx.SetBody(data)
390400
return true
@@ -405,6 +415,13 @@ func (h *FileHandler) serveNotFound(ctx *fasthttp.RequestCtx) {
405415
// Fall back to the embedded default 404.html.
406416
if data, err := fs.ReadFile(defaults.FS, "public/404.html"); err == nil {
407417
ctx.Response.Header.Set("Content-Type", "text/html; charset=utf-8")
418+
419+
// Set ETag for embedded 404 page.
420+
if h.cfg.Headers.EnableETags {
421+
etag := computeETag(data)
422+
ctx.Response.Header.Set("ETag", `W/"`+etag+`"`)
423+
}
424+
408425
ctx.SetStatusCode(fasthttp.StatusNotFound)
409426
ctx.SetBody(data)
410427
return

internal/handler/file_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,82 @@ func TestEmbedFallback_404HTML(t *testing.T) {
618618
}
619619
}
620620

621+
// TestEmbedFallback_StyleCSS_ETag verifies that /style.css served from the
622+
// embedded FS includes an ETag header when enable_etags is true, and omits it
623+
// when enable_etags is false.
624+
func TestEmbedFallback_StyleCSS_ETag(t *testing.T) {
625+
t.Run("etag enabled", func(t *testing.T) {
626+
cfg := setupEmptyRootCfg(t)
627+
cfg.Headers.EnableETags = true
628+
c := cache.NewCache(cfg.Cache.MaxBytes)
629+
h := handler.BuildHandler(cfg, c)
630+
631+
ctx := newTestCtx("GET", "/style.css")
632+
h(ctx)
633+
634+
if ctx.Response.StatusCode() != fasthttp.StatusOK {
635+
t.Fatalf("status = %d, want 200", ctx.Response.StatusCode())
636+
}
637+
etag := string(ctx.Response.Header.Peek("ETag"))
638+
if etag == "" {
639+
t.Error("ETag header must be set on embedded style.css when enable_etags=true")
640+
}
641+
})
642+
643+
t.Run("etag disabled", func(t *testing.T) {
644+
cfg := setupEmptyRootCfg(t)
645+
cfg.Headers.EnableETags = false
646+
c := cache.NewCache(cfg.Cache.MaxBytes)
647+
h := handler.BuildHandler(cfg, c)
648+
649+
ctx := newTestCtx("GET", "/style.css")
650+
h(ctx)
651+
652+
etag := string(ctx.Response.Header.Peek("ETag"))
653+
if etag != "" {
654+
t.Errorf("ETag header must NOT be set when enable_etags=false, got %q", etag)
655+
}
656+
})
657+
}
658+
659+
// TestEmbedFallback_404HTML_ETag verifies that the embedded 404.html includes
660+
// an ETag header when enable_etags is true.
661+
func TestEmbedFallback_404HTML_ETag(t *testing.T) {
662+
cfg := setupEmptyRootCfg(t)
663+
cfg.Headers.EnableETags = true
664+
cfg.Files.NotFound = ""
665+
c := cache.NewCache(cfg.Cache.MaxBytes)
666+
h := handler.BuildHandler(cfg, c)
667+
668+
ctx := newTestCtx("GET", "/totally-unknown-file.xyz")
669+
h(ctx)
670+
671+
if ctx.Response.StatusCode() != fasthttp.StatusNotFound {
672+
t.Fatalf("status = %d, want 404", ctx.Response.StatusCode())
673+
}
674+
etag := string(ctx.Response.Header.Peek("ETag"))
675+
if etag == "" {
676+
t.Error("ETag header must be set on embedded 404.html when enable_etags=true")
677+
}
678+
}
679+
680+
// TestEmbedFallback_StyleCSS_CacheControl verifies that /style.css served from
681+
// the embedded FS includes a Cache-Control header with the configured max-age.
682+
func TestEmbedFallback_StyleCSS_CacheControl(t *testing.T) {
683+
cfg := setupEmptyRootCfg(t)
684+
cfg.Headers.StaticMaxAge = 7200
685+
c := cache.NewCache(cfg.Cache.MaxBytes)
686+
h := handler.BuildHandler(cfg, c)
687+
688+
ctx := newTestCtx("GET", "/style.css")
689+
h(ctx)
690+
691+
cc := string(ctx.Response.Header.Peek("Cache-Control"))
692+
if !strings.Contains(cc, "max-age=7200") {
693+
t.Errorf("Cache-Control = %q, want it to contain max-age=7200", cc)
694+
}
695+
}
696+
621697
// TestEmbedFallback_SubpathNotServed verifies that the embed fallback only
622698
// handles flat filenames. A URL like /sub/index.html must NOT be served from
623699
// the embedded FS (guard against sub-path traversal) and must return 404.

public/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
<div class="kv"><span class="key ok">✓ cache</span> <span class="val dim">in-memory LRU, configurable TTL</span></div>
6060
<div class="kv"><span class="key ok">✓ compress</span> <span class="val dim">gzip on-the-fly + pre-compressed sidecar files</span></div>
6161
<div class="kv"><span class="key ok">✓ tls</span> <span class="val dim">TLS 1.2 / 1.3, HTTP/2 via ALPN</span></div>
62-
<div class="kv"><span class="key ok">✓ headers</span> <span class="val dim">ETag, Cache-Control, CSP, CORS, HSTS</span></div>
62+
<div class="kv"><span class="key ok">✓ etags</span> <span class="val dim">SHA-256 ETags + 304 on all assets, incl. embedded fallbacks</span></div>
63+
<div class="kv"><span class="key ok">✓ headers</span> <span class="val dim">Cache-Control, CSP, CORS, HSTS</span></div>
6364
<div class="kv"><span class="key ok">✓ security</span> <span class="val dim">dotfile blocking, security headers</span></div>
6465
<div class="kv"><span class="key ok">✓ graceful</span> <span class="val dim">SIGHUP reload · SIGTERM drain &amp; shutdown</span></div>
6566
</div>

0 commit comments

Comments
 (0)