diff --git a/README.md b/README.md index 6e39cc9..9f63fca 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start | `immutable_pattern` | string | — | Glob for immutable assets | | `static_max_age` | int | `3600` | `Cache-Control` max-age for non-HTML (seconds) | | `html_max_age` | int | `0` | `Cache-Control` max-age for HTML (seconds) | +| `enable_etags` | bool | `true` | Enable ETag generation and `If-None-Match` validation for cache revalidation | ### `[security]` @@ -314,6 +315,7 @@ All environment variables override the corresponding TOML setting. Useful for co | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | +| `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` | | `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` | | `STATIC_SECURITY_CSP` | `security.csp` | | `STATIC_SECURITY_CORS_ORIGINS` | `security.cors_origins` (comma-separated) | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index fb5c728..73ee7c7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -141,6 +141,7 @@ precompressed = true # serve .gz / .br sidecar files when available immutable_pattern = "" # glob for fingerprinted assets → Cache-Control: immutable static_max_age = 3600 # max-age for non-HTML assets (seconds) html_max_age = 0 # 0 = no-cache (always revalidate HTML) +enable_etags = true # enable ETag generation and If-None-Match validation [security] block_dotfiles = true @@ -180,6 +181,7 @@ Every config field can also be set via an environment variable, which takes prec | `STATIC_COMPRESSION_ENABLED` | `compression.enabled` | | `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` | | `STATIC_COMPRESSION_LEVEL` | `compression.level` | +| `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` | | `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` | | `STATIC_SECURITY_CSP` | `security.csp` | | `STATIC_SECURITY_CORS_ORIGINS` | `security.cors_origins` (comma-separated values) | diff --git a/config.toml.example b/config.toml.example index b471f5e..8f91030 100644 --- a/config.toml.example +++ b/config.toml.example @@ -79,6 +79,11 @@ static_max_age = 3600 # Cache-Control max-age for HTML files (seconds). 0 = always revalidate (no-cache). html_max_age = 0 +# Enable ETag generation and If-None-Match validation for cache revalidation. +# When enabled, ETags are computed for all files and used to return 304 Not Modified +# responses when clients send matching If-None-Match headers. Default: true. +enable_etags = true + [security] # Block requests for files whose path components start with "." (dotfiles). block_dotfiles = true diff --git a/internal/config/config.go b/internal/config/config.go index c7bc2cf..0d12c03 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,6 +99,8 @@ type HeadersConfig struct { StaticMaxAge int `toml:"static_max_age"` // HTMLMaxAge is the Cache-Control max-age for HTML files. Default: 0. HTMLMaxAge int `toml:"html_max_age"` + // EnableETags enables ETag generation and If-None-Match validation. Default: true. + EnableETags bool `toml:"enable_etags"` } // SecurityConfig controls security settings. @@ -164,6 +166,7 @@ func applyDefaults(cfg *Config) { cfg.Headers.StaticMaxAge = 3600 cfg.Headers.HTMLMaxAge = 0 + cfg.Headers.EnableETags = true cfg.Security.BlockDotfiles = true cfg.Security.DirectoryListing = false @@ -277,4 +280,8 @@ func applyEnvOverrides(cfg *Config) { } cfg.Security.CORSOrigins = parts } + + if v := os.Getenv("STATIC_HEADERS_ENABLE_ETAGS"); v != "" { + cfg.Headers.EnableETags = strings.EqualFold(v, "true") || v == "1" + } } diff --git a/internal/defaults/public/style.css b/internal/defaults/public/style.css index 5cac634..7c106d7 100644 --- a/internal/defaults/public/style.css +++ b/internal/defaults/public/style.css @@ -11,12 +11,13 @@ --yellow: #e3b341; --red: #f85149; --mono: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace; + --sans: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } html, body { height: 100%; } body { - font-family: var(--mono); + font-family: var(--sans); background: var(--bg); color: var(--text); font-size: 14px; diff --git a/internal/handler/file.go b/internal/handler/file.go index 8b0d286..f207db3 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -87,7 +87,7 @@ func (h *FileHandler) HandleRequest(ctx *fasthttp.RequestCtx) { cacheKey := headers.CacheKeyForPath(urlPath, h.cfg.Files.Index) if h.cfg.Cache.Enabled && h.cache != nil { if cached, ok := h.cache.Get(cacheKey); ok { - if headers.CheckNotModified(ctx, cached) { + if headers.CheckNotModified(ctx, cached, h.cfg.Headers.EnableETags) { return } h.serveFromCache(ctx, cacheKey, cached) @@ -110,7 +110,7 @@ func (h *FileHandler) HandleRequest(ctx *fasthttp.RequestCtx) { // case the directory-resolved key is cached even though the bare path isn't. if h.cfg.Cache.Enabled && h.cache != nil && canonicalURL != cacheKey { if cached, ok := h.cache.Get(canonicalURL); ok { - if headers.CheckNotModified(ctx, cached) { + if headers.CheckNotModified(ctx, cached, h.cfg.Headers.EnableETags) { return } h.serveFromCache(ctx, canonicalURL, cached) @@ -383,8 +383,18 @@ func (h *FileHandler) serveEmbedded(ctx *fasthttp.RequestCtx, urlPath string) bo return false } ct := detectContentType(name, data) + etag := computeETag(data) + ctx.Response.Header.Set("Content-Type", ct) ctx.Response.Header.Set("X-Cache", "MISS") + + // Set ETag and Cache-Control headers for embedded assets. + if h.cfg.Headers.EnableETags { + ctx.Response.Header.Set("ETag", `W/"`+etag+`"`) + } + ctx.Response.Header.Set("Cache-Control", "public, max-age="+strconv.Itoa(h.cfg.Headers.StaticMaxAge)) + ctx.Response.Header.Add("Vary", "Accept-Encoding") + ctx.SetStatusCode(fasthttp.StatusOK) ctx.SetBody(data) return true @@ -405,6 +415,13 @@ func (h *FileHandler) serveNotFound(ctx *fasthttp.RequestCtx) { // Fall back to the embedded default 404.html. if data, err := fs.ReadFile(defaults.FS, "public/404.html"); err == nil { ctx.Response.Header.Set("Content-Type", "text/html; charset=utf-8") + + // Set ETag for embedded 404 page. + if h.cfg.Headers.EnableETags { + etag := computeETag(data) + ctx.Response.Header.Set("ETag", `W/"`+etag+`"`) + } + ctx.SetStatusCode(fasthttp.StatusNotFound) ctx.SetBody(data) return diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index f91afb8..8a50718 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -51,6 +51,7 @@ func setupTestDir(t *testing.T) (string, *config.Config) { cfg.Security.CSP = "default-src 'self'" cfg.Headers.StaticMaxAge = 3600 cfg.Headers.HTMLMaxAge = 0 + cfg.Headers.EnableETags = true return root, cfg } @@ -617,6 +618,82 @@ func TestEmbedFallback_404HTML(t *testing.T) { } } +// TestEmbedFallback_StyleCSS_ETag verifies that /style.css served from the +// embedded FS includes an ETag header when enable_etags is true, and omits it +// when enable_etags is false. +func TestEmbedFallback_StyleCSS_ETag(t *testing.T) { + t.Run("etag enabled", func(t *testing.T) { + cfg := setupEmptyRootCfg(t) + cfg.Headers.EnableETags = true + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandler(cfg, c) + + ctx := newTestCtx("GET", "/style.css") + h(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusOK { + t.Fatalf("status = %d, want 200", ctx.Response.StatusCode()) + } + etag := string(ctx.Response.Header.Peek("ETag")) + if etag == "" { + t.Error("ETag header must be set on embedded style.css when enable_etags=true") + } + }) + + t.Run("etag disabled", func(t *testing.T) { + cfg := setupEmptyRootCfg(t) + cfg.Headers.EnableETags = false + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandler(cfg, c) + + ctx := newTestCtx("GET", "/style.css") + h(ctx) + + etag := string(ctx.Response.Header.Peek("ETag")) + if etag != "" { + t.Errorf("ETag header must NOT be set when enable_etags=false, got %q", etag) + } + }) +} + +// TestEmbedFallback_404HTML_ETag verifies that the embedded 404.html includes +// an ETag header when enable_etags is true. +func TestEmbedFallback_404HTML_ETag(t *testing.T) { + cfg := setupEmptyRootCfg(t) + cfg.Headers.EnableETags = true + cfg.Files.NotFound = "" + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandler(cfg, c) + + ctx := newTestCtx("GET", "/totally-unknown-file.xyz") + h(ctx) + + if ctx.Response.StatusCode() != fasthttp.StatusNotFound { + t.Fatalf("status = %d, want 404", ctx.Response.StatusCode()) + } + etag := string(ctx.Response.Header.Peek("ETag")) + if etag == "" { + t.Error("ETag header must be set on embedded 404.html when enable_etags=true") + } +} + +// TestEmbedFallback_StyleCSS_CacheControl verifies that /style.css served from +// the embedded FS includes a Cache-Control header with the configured max-age. +func TestEmbedFallback_StyleCSS_CacheControl(t *testing.T) { + cfg := setupEmptyRootCfg(t) + cfg.Headers.StaticMaxAge = 7200 + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandler(cfg, c) + + ctx := newTestCtx("GET", "/style.css") + h(ctx) + + cc := string(ctx.Response.Header.Peek("Cache-Control")) + if !strings.Contains(cc, "max-age=7200") { + t.Errorf("Cache-Control = %q, want it to contain max-age=7200", cc) + } +} + // TestEmbedFallback_SubpathNotServed verifies that the embed fallback only // handles flat filenames. A URL like /sub/index.html must NOT be served from // the embedded FS (guard against sub-path traversal) and must return 404. diff --git a/internal/headers/headers.go b/internal/headers/headers.go index 87d2e16..9293ecb 100644 --- a/internal/headers/headers.go +++ b/internal/headers/headers.go @@ -48,25 +48,29 @@ func parseHTTPTime(s string) (time.Time, error) { // CheckNotModified evaluates conditional request headers. // Returns true and writes a 304 response if the resource has not changed. // Uses pre-formatted header strings when available (PERF-003). -func CheckNotModified(ctx *fasthttp.RequestCtx, f *cache.CachedFile) bool { - // Resolve the ETag value to use. - var etagStr string - if f.ETagHeader != "" { - etagStr = f.ETagHeader - } else { - etagStr = f.ETagFull - if etagStr == "" { - etagStr = `W/"` + f.ETag + `"` +// When enableETags is false, ETag-based validation is skipped. +func CheckNotModified(ctx *fasthttp.RequestCtx, f *cache.CachedFile, enableETags bool) bool { + // Check If-None-Match (ETag-based) only if ETags are enabled. + if enableETags { + // Resolve the ETag value to use. + var etagStr string + if f.ETagHeader != "" { + etagStr = f.ETagHeader + } else { + etagStr = f.ETagFull + if etagStr == "" { + etagStr = `W/"` + f.ETag + `"` + } } - } - if inm := string(ctx.Request.Header.Peek("If-None-Match")); inm != "" { - if ETagMatches(inm, etagStr) { - ctx.Response.Header.Set("Etag", etagStr) - ctx.SetStatusCode(fasthttp.StatusNotModified) - return true + if inm := string(ctx.Request.Header.Peek("If-None-Match")); inm != "" { + if ETagMatches(inm, etagStr) { + ctx.Response.Header.Set("Etag", etagStr) + ctx.SetStatusCode(fasthttp.StatusNotModified) + return true + } + return false } - return false } if ims := string(ctx.Request.Header.Peek("If-Modified-Since")); ims != "" { @@ -125,16 +129,20 @@ func ETagMatches(ifNoneMatch, etag string) bool { // When the CachedFile has pre-formatted header strings (from InitHeaders + // InitCacheControl), they are assigned directly, bypassing string formatting // entirely (PERF-003). +// When cfg.EnableETags is false, ETag headers are not set. func SetCacheHeaders(ctx *fasthttp.RequestCtx, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) { - // Pre-formatted fast path: assign pre-computed strings directly. - if f.ETagHeader != "" { - ctx.Response.Header.Set("Etag", f.ETagHeader) - } else { - etag := f.ETagFull - if etag == "" { - etag = `W/"` + f.ETag + `"` + // Set ETag header only if ETags are enabled. + if cfg.EnableETags { + // Pre-formatted fast path: assign pre-computed strings directly. + if f.ETagHeader != "" { + ctx.Response.Header.Set("Etag", f.ETagHeader) + } else { + etag := f.ETagFull + if etag == "" { + etag = `W/"` + f.ETag + `"` + } + ctx.Response.Header.Set("ETag", etag) } - ctx.Response.Header.Set("ETag", etag) } if f.LastModHeader != "" { diff --git a/internal/headers/headers_test.go b/internal/headers/headers_test.go index d486b4a..2ded60d 100644 --- a/internal/headers/headers_test.go +++ b/internal/headers/headers_test.go @@ -51,7 +51,7 @@ func TestCheckNotModifiedIfNoneMatch(t *testing.T) { ctx.Request.SetRequestURI("/app.js") ctx.Request.Header.Set("If-None-Match", `W/"abcdef1234567890"`) - if !headers.CheckNotModified(&ctx, f) { + if !headers.CheckNotModified(&ctx, f, true) { t.Fatal("CheckNotModified returned false, want true") } if ctx.Response.StatusCode() != fasthttp.StatusNotModified { @@ -66,7 +66,7 @@ func TestCheckNotModifiedIfModifiedSince(t *testing.T) { ctx.Request.SetRequestURI("/page.html") ctx.Request.Header.Set("If-Modified-Since", time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).Format(cache.HTTPTimeFormat)) - if !headers.CheckNotModified(&ctx, f) { + if !headers.CheckNotModified(&ctx, f, true) { t.Fatal("CheckNotModified returned false, want true") } if ctx.Response.StatusCode() != fasthttp.StatusNotModified { @@ -81,14 +81,14 @@ func TestCheckNotModifiedReturnsFalseOnMismatch(t *testing.T) { ctx.Request.SetRequestURI("/data.json") ctx.Request.Header.Set("If-None-Match", `W/"differentetag0000"`) - if headers.CheckNotModified(&ctx, f) { + if headers.CheckNotModified(&ctx, f, true) { t.Fatal("CheckNotModified returned true, want false") } } func TestSetCacheHeadersHTML(t *testing.T) { f := makeCachedFile([]byte(""), "text/html") - cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600} + cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600, EnableETags: true} var ctx fasthttp.RequestCtx headers.SetCacheHeaders(&ctx, "/index.html", f, cfg) @@ -106,7 +106,7 @@ func TestSetCacheHeadersHTML(t *testing.T) { func TestSetCacheHeadersStaticImmutable(t *testing.T) { f := makeCachedFile([]byte("console.log(1)"), "application/javascript") - cfg := &config.HeadersConfig{StaticMaxAge: 31536000, ImmutablePattern: "*.js"} + cfg := &config.HeadersConfig{StaticMaxAge: 31536000, ImmutablePattern: "*.js", EnableETags: true} var ctx fasthttp.RequestCtx headers.SetCacheHeaders(&ctx, "/assets/app.abc123.js", f, cfg) @@ -117,6 +117,37 @@ func TestSetCacheHeadersStaticImmutable(t *testing.T) { } } +func TestCheckNotModifiedETagsDisabled(t *testing.T) { + f := makeCachedFile([]byte("console.log(1)"), "application/javascript") + var ctx fasthttp.RequestCtx + ctx.Request.Header.SetMethod("GET") + ctx.Request.SetRequestURI("/app.js") + ctx.Request.Header.Set("If-None-Match", `W/"abcdef1234567890"`) + + // When ETags are disabled, CheckNotModified should return false even with matching ETag + if headers.CheckNotModified(&ctx, f, false) { + t.Fatal("CheckNotModified returned true, want false when ETags disabled") + } + if ctx.Response.StatusCode() == fasthttp.StatusNotModified { + t.Fatalf("status = %d, want not 304 when ETags disabled", ctx.Response.StatusCode()) + } +} + +func TestSetCacheHeadersETagsDisabled(t *testing.T) { + f := makeCachedFile([]byte(""), "text/html") + cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600, EnableETags: false} + var ctx fasthttp.RequestCtx + + headers.SetCacheHeaders(&ctx, "/index.html", f, cfg) + + if etag := string(ctx.Response.Header.Peek("ETag")); etag != "" { + t.Fatalf("ETag = %q, want empty when disabled", etag) + } + if cc := string(ctx.Response.Header.Peek("Cache-Control")); cc != "no-cache" { + t.Fatalf("Cache-Control = %q, want no-cache", cc) + } +} + func TestETagMatches(t *testing.T) { if !headers.ETagMatches("*", `W/"abc"`) { t.Fatal("ETagMatches wildcard = false, want true") diff --git a/public/index.html b/public/index.html index 26fbd3b..47362bf 100644 --- a/public/index.html +++ b/public/index.html @@ -59,7 +59,8 @@
✓ cache in-memory LRU, configurable TTL
✓ compress gzip on-the-fly + pre-compressed sidecar files
✓ tls TLS 1.2 / 1.3, HTTP/2 via ALPN
-
✓ headers ETag, Cache-Control, CSP, CORS, HSTS
+
✓ etags SHA-256 ETags + 304 on all assets, incl. embedded fallbacks
+
✓ headers Cache-Control, CSP, CORS, HSTS
✓ security dotfile blocking, security headers
✓ graceful SIGHUP reload · SIGTERM drain & shutdown