Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
5 changes: 5 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion internal/defaults/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 19 additions & 2 deletions internal/handler/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
77 changes: 77 additions & 0 deletions internal/handler/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down
56 changes: 32 additions & 24 deletions internal/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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 != "" {
Expand Down
41 changes: 36 additions & 5 deletions internal/headers/headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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("<html>"), "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)
Expand All @@ -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)
Expand All @@ -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("<html>"), "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")
Expand Down
3 changes: 2 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
<div class="kv"><span class="key ok">✓ cache</span> <span class="val dim">in-memory LRU, configurable TTL</span></div>
<div class="kv"><span class="key ok">✓ compress</span> <span class="val dim">gzip on-the-fly + pre-compressed sidecar files</span></div>
<div class="kv"><span class="key ok">✓ tls</span> <span class="val dim">TLS 1.2 / 1.3, HTTP/2 via ALPN</span></div>
<div class="kv"><span class="key ok">✓ headers</span> <span class="val dim">ETag, Cache-Control, CSP, CORS, HSTS</span></div>
<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>
<div class="kv"><span class="key ok">✓ headers</span> <span class="val dim">Cache-Control, CSP, CORS, HSTS</span></div>
<div class="kv"><span class="key ok">✓ security</span> <span class="val dim">dotfile blocking, security headers</span></div>
<div class="kv"><span class="key ok">✓ graceful</span> <span class="val dim">SIGHUP reload · SIGTERM drain &amp; shutdown</span></div>
</div>
Expand Down
Loading