Skip to content

Commit 0be7e5d

Browse files
authored
Merge pull request #7 from BackendStack21/feat/outfit-font-and-etag-flag
Outfit font and Etags flag
2 parents 84a7327 + 00ec352 commit 0be7e5d

File tree

10 files changed

+184
-33
lines changed

10 files changed

+184
-33
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start
271271
| `immutable_pattern` | string || Glob for immutable assets |
272272
| `static_max_age` | int | `3600` | `Cache-Control` max-age for non-HTML (seconds) |
273273
| `html_max_age` | int | `0` | `Cache-Control` max-age for HTML (seconds) |
274+
| `enable_etags` | bool | `true` | Enable ETag generation and `If-None-Match` validation for cache revalidation |
274275

275276
### `[security]`
276277

@@ -314,6 +315,7 @@ All environment variables override the corresponding TOML setting. Useful for co
314315
| `STATIC_COMPRESSION_ENABLED` | `compression.enabled` |
315316
| `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` |
316317
| `STATIC_COMPRESSION_LEVEL` | `compression.level` |
318+
| `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` |
317319
| `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` |
318320
| `STATIC_SECURITY_CSP` | `security.csp` |
319321
| `STATIC_SECURITY_CORS_ORIGINS` | `security.cors_origins` (comma-separated) |

USER_GUIDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ precompressed = true # serve .gz / .br sidecar files when available
141141
immutable_pattern = "" # glob for fingerprinted assets → Cache-Control: immutable
142142
static_max_age = 3600 # max-age for non-HTML assets (seconds)
143143
html_max_age = 0 # 0 = no-cache (always revalidate HTML)
144+
enable_etags = true # enable ETag generation and If-None-Match validation
144145

145146
[security]
146147
block_dotfiles = true
@@ -180,6 +181,7 @@ Every config field can also be set via an environment variable, which takes prec
180181
| `STATIC_COMPRESSION_ENABLED` | `compression.enabled` |
181182
| `STATIC_COMPRESSION_MIN_SIZE` | `compression.min_size` |
182183
| `STATIC_COMPRESSION_LEVEL` | `compression.level` |
184+
| `STATIC_HEADERS_ENABLE_ETAGS` | `headers.enable_etags` |
183185
| `STATIC_SECURITY_BLOCK_DOTFILES` | `security.block_dotfiles` |
184186
| `STATIC_SECURITY_CSP` | `security.csp` |
185187
| `STATIC_SECURITY_CORS_ORIGINS` | `security.cors_origins` (comma-separated values) |

config.toml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ static_max_age = 3600
7979
# Cache-Control max-age for HTML files (seconds). 0 = always revalidate (no-cache).
8080
html_max_age = 0
8181

82+
# Enable ETag generation and If-None-Match validation for cache revalidation.
83+
# When enabled, ETags are computed for all files and used to return 304 Not Modified
84+
# responses when clients send matching If-None-Match headers. Default: true.
85+
enable_etags = true
86+
8287
[security]
8388
# Block requests for files whose path components start with "." (dotfiles).
8489
block_dotfiles = true

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ type HeadersConfig struct {
9999
StaticMaxAge int `toml:"static_max_age"`
100100
// HTMLMaxAge is the Cache-Control max-age for HTML files. Default: 0.
101101
HTMLMaxAge int `toml:"html_max_age"`
102+
// EnableETags enables ETag generation and If-None-Match validation. Default: true.
103+
EnableETags bool `toml:"enable_etags"`
102104
}
103105

104106
// SecurityConfig controls security settings.
@@ -164,6 +166,7 @@ func applyDefaults(cfg *Config) {
164166

165167
cfg.Headers.StaticMaxAge = 3600
166168
cfg.Headers.HTMLMaxAge = 0
169+
cfg.Headers.EnableETags = true
167170

168171
cfg.Security.BlockDotfiles = true
169172
cfg.Security.DirectoryListing = false
@@ -277,4 +280,8 @@ func applyEnvOverrides(cfg *Config) {
277280
}
278281
cfg.Security.CORSOrigins = parts
279282
}
283+
284+
if v := os.Getenv("STATIC_HEADERS_ENABLE_ETAGS"); v != "" {
285+
cfg.Headers.EnableETags = strings.EqualFold(v, "true") || v == "1"
286+
}
280287
}

internal/defaults/public/style.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
--yellow: #e3b341;
1212
--red: #f85149;
1313
--mono: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace;
14+
--sans: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1415
}
1516

1617
html, body { height: 100%; }
1718

1819
body {
19-
font-family: var(--mono);
20+
font-family: var(--sans);
2021
background: var(--bg);
2122
color: var(--text);
2223
font-size: 14px;

internal/handler/file.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func (h *FileHandler) HandleRequest(ctx *fasthttp.RequestCtx) {
8787
cacheKey := headers.CacheKeyForPath(urlPath, h.cfg.Files.Index)
8888
if h.cfg.Cache.Enabled && h.cache != nil {
8989
if cached, ok := h.cache.Get(cacheKey); ok {
90-
if headers.CheckNotModified(ctx, cached) {
90+
if headers.CheckNotModified(ctx, cached, h.cfg.Headers.EnableETags) {
9191
return
9292
}
9393
h.serveFromCache(ctx, cacheKey, cached)
@@ -110,7 +110,7 @@ func (h *FileHandler) HandleRequest(ctx *fasthttp.RequestCtx) {
110110
// case the directory-resolved key is cached even though the bare path isn't.
111111
if h.cfg.Cache.Enabled && h.cache != nil && canonicalURL != cacheKey {
112112
if cached, ok := h.cache.Get(canonicalURL); ok {
113-
if headers.CheckNotModified(ctx, cached) {
113+
if headers.CheckNotModified(ctx, cached, h.cfg.Headers.EnableETags) {
114114
return
115115
}
116116
h.serveFromCache(ctx, canonicalURL, cached)
@@ -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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func setupTestDir(t *testing.T) (string, *config.Config) {
5151
cfg.Security.CSP = "default-src 'self'"
5252
cfg.Headers.StaticMaxAge = 3600
5353
cfg.Headers.HTMLMaxAge = 0
54+
cfg.Headers.EnableETags = true
5455

5556
return root, cfg
5657
}
@@ -617,6 +618,82 @@ func TestEmbedFallback_404HTML(t *testing.T) {
617618
}
618619
}
619620

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+
620697
// TestEmbedFallback_SubpathNotServed verifies that the embed fallback only
621698
// handles flat filenames. A URL like /sub/index.html must NOT be served from
622699
// the embedded FS (guard against sub-path traversal) and must return 404.

internal/headers/headers.go

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,29 @@ func parseHTTPTime(s string) (time.Time, error) {
4848
// CheckNotModified evaluates conditional request headers.
4949
// Returns true and writes a 304 response if the resource has not changed.
5050
// Uses pre-formatted header strings when available (PERF-003).
51-
func CheckNotModified(ctx *fasthttp.RequestCtx, f *cache.CachedFile) bool {
52-
// Resolve the ETag value to use.
53-
var etagStr string
54-
if f.ETagHeader != "" {
55-
etagStr = f.ETagHeader
56-
} else {
57-
etagStr = f.ETagFull
58-
if etagStr == "" {
59-
etagStr = `W/"` + f.ETag + `"`
51+
// When enableETags is false, ETag-based validation is skipped.
52+
func CheckNotModified(ctx *fasthttp.RequestCtx, f *cache.CachedFile, enableETags bool) bool {
53+
// Check If-None-Match (ETag-based) only if ETags are enabled.
54+
if enableETags {
55+
// Resolve the ETag value to use.
56+
var etagStr string
57+
if f.ETagHeader != "" {
58+
etagStr = f.ETagHeader
59+
} else {
60+
etagStr = f.ETagFull
61+
if etagStr == "" {
62+
etagStr = `W/"` + f.ETag + `"`
63+
}
6064
}
61-
}
6265

63-
if inm := string(ctx.Request.Header.Peek("If-None-Match")); inm != "" {
64-
if ETagMatches(inm, etagStr) {
65-
ctx.Response.Header.Set("Etag", etagStr)
66-
ctx.SetStatusCode(fasthttp.StatusNotModified)
67-
return true
66+
if inm := string(ctx.Request.Header.Peek("If-None-Match")); inm != "" {
67+
if ETagMatches(inm, etagStr) {
68+
ctx.Response.Header.Set("Etag", etagStr)
69+
ctx.SetStatusCode(fasthttp.StatusNotModified)
70+
return true
71+
}
72+
return false
6873
}
69-
return false
7074
}
7175

7276
if ims := string(ctx.Request.Header.Peek("If-Modified-Since")); ims != "" {
@@ -125,16 +129,20 @@ func ETagMatches(ifNoneMatch, etag string) bool {
125129
// When the CachedFile has pre-formatted header strings (from InitHeaders +
126130
// InitCacheControl), they are assigned directly, bypassing string formatting
127131
// entirely (PERF-003).
132+
// When cfg.EnableETags is false, ETag headers are not set.
128133
func SetCacheHeaders(ctx *fasthttp.RequestCtx, urlPath string, f *cache.CachedFile, cfg *config.HeadersConfig) {
129-
// Pre-formatted fast path: assign pre-computed strings directly.
130-
if f.ETagHeader != "" {
131-
ctx.Response.Header.Set("Etag", f.ETagHeader)
132-
} else {
133-
etag := f.ETagFull
134-
if etag == "" {
135-
etag = `W/"` + f.ETag + `"`
134+
// Set ETag header only if ETags are enabled.
135+
if cfg.EnableETags {
136+
// Pre-formatted fast path: assign pre-computed strings directly.
137+
if f.ETagHeader != "" {
138+
ctx.Response.Header.Set("Etag", f.ETagHeader)
139+
} else {
140+
etag := f.ETagFull
141+
if etag == "" {
142+
etag = `W/"` + f.ETag + `"`
143+
}
144+
ctx.Response.Header.Set("ETag", etag)
136145
}
137-
ctx.Response.Header.Set("ETag", etag)
138146
}
139147

140148
if f.LastModHeader != "" {

internal/headers/headers_test.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func TestCheckNotModifiedIfNoneMatch(t *testing.T) {
5151
ctx.Request.SetRequestURI("/app.js")
5252
ctx.Request.Header.Set("If-None-Match", `W/"abcdef1234567890"`)
5353

54-
if !headers.CheckNotModified(&ctx, f) {
54+
if !headers.CheckNotModified(&ctx, f, true) {
5555
t.Fatal("CheckNotModified returned false, want true")
5656
}
5757
if ctx.Response.StatusCode() != fasthttp.StatusNotModified {
@@ -66,7 +66,7 @@ func TestCheckNotModifiedIfModifiedSince(t *testing.T) {
6666
ctx.Request.SetRequestURI("/page.html")
6767
ctx.Request.Header.Set("If-Modified-Since", time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).Format(cache.HTTPTimeFormat))
6868

69-
if !headers.CheckNotModified(&ctx, f) {
69+
if !headers.CheckNotModified(&ctx, f, true) {
7070
t.Fatal("CheckNotModified returned false, want true")
7171
}
7272
if ctx.Response.StatusCode() != fasthttp.StatusNotModified {
@@ -81,14 +81,14 @@ func TestCheckNotModifiedReturnsFalseOnMismatch(t *testing.T) {
8181
ctx.Request.SetRequestURI("/data.json")
8282
ctx.Request.Header.Set("If-None-Match", `W/"differentetag0000"`)
8383

84-
if headers.CheckNotModified(&ctx, f) {
84+
if headers.CheckNotModified(&ctx, f, true) {
8585
t.Fatal("CheckNotModified returned true, want false")
8686
}
8787
}
8888

8989
func TestSetCacheHeadersHTML(t *testing.T) {
9090
f := makeCachedFile([]byte("<html>"), "text/html")
91-
cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600}
91+
cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600, EnableETags: true}
9292
var ctx fasthttp.RequestCtx
9393

9494
headers.SetCacheHeaders(&ctx, "/index.html", f, cfg)
@@ -106,7 +106,7 @@ func TestSetCacheHeadersHTML(t *testing.T) {
106106

107107
func TestSetCacheHeadersStaticImmutable(t *testing.T) {
108108
f := makeCachedFile([]byte("console.log(1)"), "application/javascript")
109-
cfg := &config.HeadersConfig{StaticMaxAge: 31536000, ImmutablePattern: "*.js"}
109+
cfg := &config.HeadersConfig{StaticMaxAge: 31536000, ImmutablePattern: "*.js", EnableETags: true}
110110
var ctx fasthttp.RequestCtx
111111

112112
headers.SetCacheHeaders(&ctx, "/assets/app.abc123.js", f, cfg)
@@ -117,6 +117,37 @@ func TestSetCacheHeadersStaticImmutable(t *testing.T) {
117117
}
118118
}
119119

120+
func TestCheckNotModifiedETagsDisabled(t *testing.T) {
121+
f := makeCachedFile([]byte("console.log(1)"), "application/javascript")
122+
var ctx fasthttp.RequestCtx
123+
ctx.Request.Header.SetMethod("GET")
124+
ctx.Request.SetRequestURI("/app.js")
125+
ctx.Request.Header.Set("If-None-Match", `W/"abcdef1234567890"`)
126+
127+
// When ETags are disabled, CheckNotModified should return false even with matching ETag
128+
if headers.CheckNotModified(&ctx, f, false) {
129+
t.Fatal("CheckNotModified returned true, want false when ETags disabled")
130+
}
131+
if ctx.Response.StatusCode() == fasthttp.StatusNotModified {
132+
t.Fatalf("status = %d, want not 304 when ETags disabled", ctx.Response.StatusCode())
133+
}
134+
}
135+
136+
func TestSetCacheHeadersETagsDisabled(t *testing.T) {
137+
f := makeCachedFile([]byte("<html>"), "text/html")
138+
cfg := &config.HeadersConfig{HTMLMaxAge: 0, StaticMaxAge: 3600, EnableETags: false}
139+
var ctx fasthttp.RequestCtx
140+
141+
headers.SetCacheHeaders(&ctx, "/index.html", f, cfg)
142+
143+
if etag := string(ctx.Response.Header.Peek("ETag")); etag != "" {
144+
t.Fatalf("ETag = %q, want empty when disabled", etag)
145+
}
146+
if cc := string(ctx.Response.Header.Peek("Cache-Control")); cc != "no-cache" {
147+
t.Fatalf("Cache-Control = %q, want no-cache", cc)
148+
}
149+
}
150+
120151
func TestETagMatches(t *testing.T) {
121152
if !headers.ETagMatches("*", `W/"abc"`) {
122153
t.Fatal("ETagMatches wildcard = false, want true")

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)