Skip to content

Commit c0ee494

Browse files
committed
feat: use Outfit font and make ETags optional
- Update default page font from monospace to Outfit (sans-serif) for better readability - Add enable_etags config flag to make ETag generation optional (default: true) - Update CheckNotModified() to respect the enable_etags setting - Update SetCacheHeaders() to conditionally set ETag headers - Add comprehensive tests for ETag feature flag behavior - Update config.toml.example with new enable_etags setting and documentation
1 parent 84a7327 commit c0ee494

File tree

9 files changed

+89
-32
lines changed

9 files changed

+89
-32
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: 2 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)

internal/handler/file_test.go

Lines changed: 1 addition & 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
}

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")

0 commit comments

Comments
 (0)