From 424b7c61b3c7dc2549c7280af3b359adfeca4874 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:32:51 +0100 Subject: [PATCH 1/7] feat: redesign default pages with terminal/dev-oriented aesthetic --- public/404.html | 266 +++++++++++++++++----------------- public/index.html | 358 +++++++++++++++++++++------------------------- 2 files changed, 294 insertions(+), 330 deletions(-) diff --git a/public/404.html b/public/404.html index 68f1488..83f3cd8 100644 --- a/public/404.html +++ b/public/404.html @@ -8,198 +8,192 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --bg: #0f172a; - --surface: #1e293b; - --border: #334155; - --accent: #38bdf8; - --accent-2: #818cf8; - --text: #f1f5f9; - --muted: #94a3b8; - --red: #f87171; - --radius: 10px; + --bg: #0d1117; + --surface: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #7d8590; + --red: #f85149; + --cyan: #79c0ff; + --mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, Consolas, monospace; } + html, body { height: 100%; } + body { - font-family: "Outfit", ui-rounded, system-ui, -apple-system, sans-serif; + font-family: var(--mono); background: var(--bg); color: var(--text); - min-height: 100vh; + font-size: 14px; + line-height: 1.6; + padding: 40px 24px; display: flex; flex-direction: column; - align-items: center; + align-items: flex-start; justify-content: center; - padding: 40px 20px; - position: relative; - overflow-x: hidden; + min-height: 100vh; + max-width: 720px; + margin: 0 auto; } - body::before { - content: ""; - position: fixed; - inset: 0; - background: - radial-gradient(ellipse 70% 45% at 50% -5%, rgba(248,113,113,.09) 0%, transparent 70%), - radial-gradient(ellipse 55% 35% at 85% 85%, rgba(129,140,248,.07) 0%, transparent 60%); - pointer-events: none; - z-index: 0; + .line { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 1.6em; } - body::after { - content: ""; - position: fixed; - inset: 0; - background-image: radial-gradient(rgba(148,163,184,.1) 1px, transparent 1px); - background-size: 28px 28px; - pointer-events: none; - z-index: 0; - } + .line + .line { margin-top: 2px; } + .spacer { margin-top: 20px; } - .card { - position: relative; - z-index: 1; + .prompt { color: var(--muted); user-select: none; } + .err { color: var(--red); } + .dim { color: var(--muted); } + + .block { background: var(--surface); border: 1px solid var(--border); - border-radius: 16px; - padding: 44px 48px; - max-width: 560px; + border-left: 3px solid var(--red); + border-radius: 6px; + padding: 16px 20px; + margin-top: 24px; width: 100%; - box-shadow: 0 24px 64px rgba(0,0,0,.4); - text-align: center; } - .code { - font-size: clamp(4rem, 18vw, 7rem); - font-weight: 800; - line-height: 1; - background: linear-gradient(135deg, var(--red) 0%, rgba(248,113,113,.5) 100%); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - margin-bottom: 16px; - letter-spacing: -.03em; + .block-title { + color: var(--muted); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: 12px; } - h1 { - font-size: 1.3rem; - font-weight: 600; - margin-bottom: 10px; + .kv { + display: flex; + gap: 12px; + line-height: 1.9; } - .sub { + .key { color: var(--muted); - font-size: .92rem; - line-height: 1.65; - margin-bottom: 36px; + flex-shrink: 0; + min-width: 80px; + } + + .val { color: var(--text); } + + .path-val { + color: var(--red); + word-break: break-all; } .actions { display: flex; - align-items: center; - justify-content: center; + gap: 12px; flex-wrap: wrap; - gap: 10px; + margin-top: 24px; } .btn { display: inline-flex; align-items: center; - gap: 7px; - border-radius: 9px; - padding: 10px 20px; - font-size: .87rem; - font-weight: 600; + gap: 6px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 7px 14px; + font-family: var(--mono); + font-size: 13px; + color: var(--text); text-decoration: none; + background: var(--surface); cursor: pointer; - border: none; - transition: opacity .15s, background .15s, border-color .15s; - font-family: inherit; - } - - .btn-primary { - background: var(--accent); - color: #0f172a; + transition: border-color .12s, color .12s; } - .btn-primary:hover { opacity: .88; } - - .btn-ghost { - background: transparent; - color: var(--muted); - border: 1px solid var(--border); + .btn:hover { + border-color: var(--cyan); + color: var(--cyan); } - .btn-ghost:hover { - border-color: var(--accent); - color: var(--accent); - } - - .divider { + .section-divider { border: none; border-top: 1px solid var(--border); - margin: 32px 0 24px; + margin: 28px 0; + width: 100%; } - .brand { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: .82rem; + .footer-line { color: var(--muted); - text-decoration: none; + font-size: 12px; } - .brand-logo { - width: 20px; - height: 20px; - border-radius: 5px; - background: linear-gradient(135deg, var(--accent), var(--accent-2)); - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - font-size: .6rem; - color: #0f172a; - letter-spacing: -.5px; - flex-shrink: 0; + a.plain { + color: var(--cyan); + text-decoration: none; } - .brand:hover { color: var(--text); } + a.plain:hover { text-decoration: underline; } - @media (max-width: 500px) { - .card { padding: 32px 24px; } + @media (max-width: 540px) { + body { font-size: 13px; padding: 24px 16px; } } -
-
404
-

Page not found

-

- The page you're looking for doesn't exist or has been moved.
- Double-check the URL or head back home. -

- -
- - - - - Go home - - -
-
+
+ $ + GET / +
- - - Served by static-web — BackendStack21 +
+ + 404 — resource not found +
+ +
+
// error details
+
+ status + 404 Not Found +
+
+ path + / +
+
+ hint + the file does not exist or was moved +
+
+ +
+ + + + + ~/ + +
+ +
+ + + + + diff --git a/public/index.html b/public/index.html index c6a3145..bd76065 100644 --- a/public/index.html +++ b/public/index.html @@ -3,252 +3,222 @@ - static-web — BackendStack21 + static-web -
-
Running
- -

Your server is live.
static-web by BackendStack21

-

- A production-grade, high-performance static file server written in Go. - Place your files in the ./public directory to start serving. -

- -
-
In-memory LRU cache
-
gzip + brotli compression
-
HTTP/2 via ALPN
-
ETag / 304 Not Modified
-
TLS 1.2 / 1.3
-
Security headers
-
CORS & dotfile protection
-
Graceful shutdown
+ +
+ $ + static-web start +
+ +
+ + server is runninglive +
+ +
+
// server info
+ +
+ language + Go
+
+ listen + :8080 +
+
+ serving + ./public/ +
+
-
- -
@@ -174,12 +174,6 @@ ~/ -

@@ -189,11 +183,6 @@ — BackendStack21
- From 0929503e0c22e452636b0913f742ef393d0621db Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:37:29 +0100 Subject: [PATCH 3/7] fix: extract inline styles to style.css to comply with default-src 'self' CSP --- public/404.html | 142 +---------------------------------- public/index.html | 150 +------------------------------------ public/style.css | 185 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 285 deletions(-) create mode 100644 public/style.css diff --git a/public/404.html b/public/404.html index 4c6af40..84df086 100644 --- a/public/404.html +++ b/public/404.html @@ -4,140 +4,7 @@ 404 — Not Found - + @@ -151,7 +18,7 @@ 404 — resource not found
-
+
// error details
status @@ -179,10 +46,9 @@
- diff --git a/public/index.html b/public/index.html index bd76065..2b03005 100644 --- a/public/index.html +++ b/public/index.html @@ -4,151 +4,7 @@ static-web - + @@ -204,8 +60,8 @@
✓ 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
-
✓ security dotfile blocking, security headers
-
✓ graceful SIGHUP reload · SIGTERM drain & shutdown
+
✓ security dotfile blocking, security headers
+
✓ graceful SIGHUP reload · SIGTERM drain & shutdown

diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..4848964 --- /dev/null +++ b/public/style.css @@ -0,0 +1,185 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d1117; + --surface: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #7d8590; + --green: #3fb950; + --cyan: #79c0ff; + --yellow: #e3b341; + --red: #f85149; + --mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +html, body { height: 100%; } + +body { + font-family: var(--mono); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.6; + padding: 40px 24px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 100vh; + max-width: 720px; + margin: 0 auto; +} + +/* layout primitives */ + +.line { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 1.6em; +} + +.line + .line { margin-top: 2px; } +.spacer { margin-top: 20px; } + +/* colour tokens */ + +.prompt { color: var(--muted); user-select: none; } +.ok { color: var(--green); } +.info { color: var(--cyan); } +.warn { color: var(--yellow); } +.err { color: var(--red); } +.dim { color: var(--muted); } + +/* info block */ + +.block { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; + padding: 16px 20px; + margin-top: 24px; + width: 100%; +} + +.block.block-error { + border-left-color: var(--red); +} + +.block-title { + color: var(--muted); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: 12px; +} + +.kv { + display: flex; + gap: 12px; + line-height: 1.9; +} + +.key { + color: var(--muted); + flex-shrink: 0; + min-width: 120px; +} + +.val { color: var(--text); } + +.path-val { + color: var(--red); + word-break: break-all; +} + +/* tag / badge */ + +.tag { + display: inline-block; + background: rgba(63,185,80,.12); + color: var(--green); + border: 1px solid rgba(63,185,80,.25); + border-radius: 4px; + padding: 1px 7px; + font-size: 12px; + vertical-align: middle; + margin-left: 6px; +} + +/* cursor */ + +.cursor { + display: inline-block; + width: 9px; + height: 1.1em; + background: var(--green); + vertical-align: text-bottom; + animation: blink 1.1s step-start infinite; + margin-left: 2px; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* buttons */ + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 7px 14px; + font-family: var(--mono); + font-size: 13px; + color: var(--text); + text-decoration: none; + background: var(--surface); + cursor: pointer; + transition: border-color .12s, color .12s; +} + +.btn:hover { + border-color: var(--cyan); + color: var(--cyan); +} + +/* divider / footer */ + +.section-divider { + border: none; + border-top: 1px solid var(--border); + margin: 28px 0; + width: 100%; +} + +.footer-line { + color: var(--muted); + font-size: 12px; +} + +a { + color: var(--cyan); + text-decoration: none; +} + +a:hover { text-decoration: underline; } + +/* responsive */ + +@media (max-width: 540px) { + body { font-size: 13px; padding: 24px 16px; } + .key { min-width: 90px; } +} From 6e6b48f4d2162849e41fff5244dbcbf4a89b8960 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:39:19 +0100 Subject: [PATCH 4/7] feat: rebrand footer to 21no.de with love emoji --- public/404.html | 4 ++-- public/index.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/404.html b/public/404.html index 84df086..3f30607 100644 --- a/public/404.html +++ b/public/404.html @@ -46,8 +46,8 @@
diff --git a/public/index.html b/public/index.html index 2b03005..2d1891d 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@
// server info
language @@ -73,7 +73,7 @@
From c0320308033b1d36942791cb601db41de526d3c5 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:40:56 +0100 Subject: [PATCH 5/7] fix: replace fingerprint-blocked named fonts with ui-monospace system stack --- public/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index 4848964..5cac634 100644 --- a/public/style.css +++ b/public/style.css @@ -10,7 +10,7 @@ --cyan: #79c0ff; --yellow: #e3b341; --red: #f85149; - --mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --mono: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace; } html, body { height: 100%; } From dcafc8dcae403f23708147a92e1f65c307931527 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:47:22 +0100 Subject: [PATCH 6/7] feat: embed default pages into binary as fallback when files.root lacks them Add internal/defaults package that embeds public/index.html, public/404.html, and public/style.css into the binary via //go:embed. Update FileHandler to fall back to these embedded assets when the configured files.root does not contain the requested file, ensuring sensible default pages are always available regardless of deployment layout. --- internal/defaults/defaults.go | 18 +++ internal/defaults/public/404.html | 54 ++++++++ internal/defaults/public/index.html | 80 ++++++++++++ internal/defaults/public/style.css | 185 ++++++++++++++++++++++++++++ internal/handler/file.go | 46 ++++++- 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 internal/defaults/defaults.go create mode 100644 internal/defaults/public/404.html create mode 100644 internal/defaults/public/index.html create mode 100644 internal/defaults/public/style.css diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go new file mode 100644 index 0000000..c172c20 --- /dev/null +++ b/internal/defaults/defaults.go @@ -0,0 +1,18 @@ +// Package defaults embeds the built-in static assets (index.html, 404.html, +// style.css) that ship with the binary. These are used as a fallback when the +// configured files.root directory does not contain those files, ensuring that +// the server always has sensible default pages regardless of deployment layout. +// +// Embedded asset paths use the "public/" prefix, e.g.: +// +// data, err := fs.ReadFile(defaults.FS, "public/index.html") +package defaults + +import "embed" + +// FS holds the embedded public/ directory tree. +// Consumers should read files via fs.ReadFile(FS, "public/") where +// is one of: index.html, 404.html, style.css. +// +//go:embed public/index.html public/404.html public/style.css +var FS embed.FS diff --git a/internal/defaults/public/404.html b/internal/defaults/public/404.html new file mode 100644 index 0000000..3f30607 --- /dev/null +++ b/internal/defaults/public/404.html @@ -0,0 +1,54 @@ + + + + + + 404 — Not Found + + + + +
+ $ + GET +
+ +
+ + 404 — resource not found +
+ +
+
// error details
+
+ status + 404 Not Found +
+
+ hint + the file does not exist or was moved +
+
+ tip + double-check the URL for typos +
+
+ + + +
+ + + + + diff --git a/internal/defaults/public/index.html b/internal/defaults/public/index.html new file mode 100644 index 0000000..2d1891d --- /dev/null +++ b/internal/defaults/public/index.html @@ -0,0 +1,80 @@ + + + + + + static-web + + + + +
+ $ + static-web start +
+ +
+ + server is runninglive +
+ +
+
// server info
+
+ project + 21no.de/static-web +
+
+ language + Go +
+
+ listen + :8080 +
+
+ serving + ./public/ +
+
+ +
+
// replace this page
+
+ step 1 + drop your files into ./public/ +
+
+ step 2 + your index.html replaces this page +
+
+ config + edit config.toml to customize +
+
+ +
+
// capabilities
+
✓ 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
+
✓ security dotfile blocking, security headers
+
✓ graceful SIGHUP reload · SIGTERM drain & shutdown
+
+ +
+ +
+ # + docs & source → + github.com/BackendStack21/static-web +
+ + + + + diff --git a/internal/defaults/public/style.css b/internal/defaults/public/style.css new file mode 100644 index 0000000..5cac634 --- /dev/null +++ b/internal/defaults/public/style.css @@ -0,0 +1,185 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d1117; + --surface: #161b22; + --border: #30363d; + --text: #e6edf3; + --muted: #7d8590; + --green: #3fb950; + --cyan: #79c0ff; + --yellow: #e3b341; + --red: #f85149; + --mono: ui-monospace, "SF Mono", Menlo, "Cascadia Code", Consolas, monospace; +} + +html, body { height: 100%; } + +body { + font-family: var(--mono); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.6; + padding: 40px 24px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 100vh; + max-width: 720px; + margin: 0 auto; +} + +/* layout primitives */ + +.line { + display: flex; + align-items: baseline; + gap: 10px; + min-height: 1.6em; +} + +.line + .line { margin-top: 2px; } +.spacer { margin-top: 20px; } + +/* colour tokens */ + +.prompt { color: var(--muted); user-select: none; } +.ok { color: var(--green); } +.info { color: var(--cyan); } +.warn { color: var(--yellow); } +.err { color: var(--red); } +.dim { color: var(--muted); } + +/* info block */ + +.block { + background: var(--surface); + border: 1px solid var(--border); + border-left: 3px solid var(--border); + border-radius: 6px; + padding: 16px 20px; + margin-top: 24px; + width: 100%; +} + +.block.block-error { + border-left-color: var(--red); +} + +.block-title { + color: var(--muted); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: 12px; +} + +.kv { + display: flex; + gap: 12px; + line-height: 1.9; +} + +.key { + color: var(--muted); + flex-shrink: 0; + min-width: 120px; +} + +.val { color: var(--text); } + +.path-val { + color: var(--red); + word-break: break-all; +} + +/* tag / badge */ + +.tag { + display: inline-block; + background: rgba(63,185,80,.12); + color: var(--green); + border: 1px solid rgba(63,185,80,.25); + border-radius: 4px; + padding: 1px 7px; + font-size: 12px; + vertical-align: middle; + margin-left: 6px; +} + +/* cursor */ + +.cursor { + display: inline-block; + width: 9px; + height: 1.1em; + background: var(--green); + vertical-align: text-bottom; + animation: blink 1.1s step-start infinite; + margin-left: 2px; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* buttons */ + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border); + border-radius: 6px; + padding: 7px 14px; + font-family: var(--mono); + font-size: 13px; + color: var(--text); + text-decoration: none; + background: var(--surface); + cursor: pointer; + transition: border-color .12s, color .12s; +} + +.btn:hover { + border-color: var(--cyan); + color: var(--cyan); +} + +/* divider / footer */ + +.section-divider { + border: none; + border-top: 1px solid var(--border); + margin: 28px 0; + width: 100%; +} + +.footer-line { + color: var(--muted); + font-size: 12px; +} + +a { + color: var(--cyan); + text-decoration: none; +} + +a:hover { text-decoration: underline; } + +/* responsive */ + +@media (max-width: 540px) { + body { font-size: 13px; padding: 24px 16px; } + .key { min-width: 90px; } +} diff --git a/internal/handler/file.go b/internal/handler/file.go index 1bb519f..b0b7fa9 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "errors" "fmt" + "io/fs" "log" "mime" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/BackendStack21/static-web/internal/cache" "github.com/BackendStack21/static-web/internal/compress" "github.com/BackendStack21/static-web/internal/config" + "github.com/BackendStack21/static-web/internal/defaults" "github.com/BackendStack21/static-web/internal/headers" "github.com/BackendStack21/static-web/internal/security" ) @@ -156,10 +158,16 @@ func (h *FileHandler) negotiateEncoding(r *http.Request, f *cache.CachedFile) ([ } // serveFromDisk reads the file from disk, populates the cache, and serves it. +// If the file does not exist on disk, it falls back to the embedded default +// assets (index.html, 404.html, style.css) before returning a 404. func (h *FileHandler) serveFromDisk(w http.ResponseWriter, r *http.Request, absPath, urlPath string) { info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { + // Try the embedded fallback assets before giving up. + if h.serveEmbedded(w, r, urlPath) { + return + } h.serveNotFound(w, r) return } @@ -249,7 +257,34 @@ func (h *FileHandler) serveLargeFile(w http.ResponseWriter, r *http.Request, abs http.ServeContent(w, r, urlPath, info.ModTime(), f) } -// serveNotFound serves a custom 404 page if configured, otherwise a plain 404. +// serveEmbedded attempts to serve a file from the embedded default assets. +// It maps the request URL path to a "public/" key in defaults.FS. +// Only the base filename is considered (no sub-directory traversal), so this +// only matches the three known defaults: index.html, 404.html, style.css. +// Returns true if the response was written, false otherwise. +func (h *FileHandler) serveEmbedded(w http.ResponseWriter, r *http.Request, urlPath string) bool { + name := strings.TrimLeft(urlPath, "/") + if name == "" { + name = "index.html" + } + // Guard: only serve flat filenames, never sub-paths. + if strings.ContainsRune(name, '/') { + return false + } + data, err := fs.ReadFile(defaults.FS, "public/"+name) + if err != nil { + return false + } + ct := detectContentType(name, data) + w.Header().Set("Content-Type", ct) + w.Header().Set("X-Cache", "MISS") + w.WriteHeader(http.StatusOK) + w.Write(data) + return true +} + +// serveNotFound serves a custom 404 page if configured, then falls back to the +// embedded default 404.html, and finally to a plain-text 404 response. // The configured path is validated via PathSafe to prevent path traversal through // a malicious config value (e.g. STATIC_FILES_NOT_FOUND=../../etc/passwd). func (h *FileHandler) serveNotFound(w http.ResponseWriter, r *http.Request) { @@ -264,6 +299,15 @@ func (h *FileHandler) serveNotFound(w http.ResponseWriter, r *http.Request) { } } } + + // Fall back to the embedded default 404.html. + if data, err := fs.ReadFile(defaults.FS, "public/404.html"); err == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + w.Write(data) + return + } + http.Error(w, "404 Not Found", http.StatusNotFound) } From 98076bc26ae294d5337471ad5f94c0827089dca8 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 7 Mar 2026 18:50:57 +0100 Subject: [PATCH 7/7] test: add unit tests for embedded default asset fallback Cover five scenarios: embedded index.html served when root is empty, embedded style.css with correct content-type, embedded 404.html served by serveNotFound when no custom page is configured, sub-path URLs correctly bypassing the embed fallback, and custom not_found config taking priority over the embedded 404. --- internal/handler/file_test.go | 136 ++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/internal/handler/file_test.go b/internal/handler/file_test.go index 62a9aa2..002b87c 100644 --- a/internal/handler/file_test.go +++ b/internal/handler/file_test.go @@ -564,6 +564,142 @@ func makeCfgWithRoot(t *testing.T, root string) *config.Config { return cfg } +// --------------------------------------------------------------------------- +// Embedded default asset fallback tests +// --------------------------------------------------------------------------- + +// setupEmptyRootCfg creates a config whose files.root is an empty temp dir, +// so every disk lookup will miss and trigger the embedded fallback path. +func setupEmptyRootCfg(t *testing.T) *config.Config { + t.Helper() + root := t.TempDir() // intentionally empty + cfg := makeCfgWithRoot(t, root) + cfg.Compression.Enabled = false // keep responses simple for content checks + return cfg +} + +// TestEmbedFallback_IndexHTML verifies that a GET / against an empty root +// returns the embedded index.html with status 200 and HTML content. +func TestEmbedFallback_IndexHTML(t *testing.T) { + cfg := setupEmptyRootCfg(t) + c := cache.NewCache(cfg.Cache.MaxBytes) + h := handler.BuildHandler(cfg, c) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200 for embedded index.html", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html for embedded index.html", ct) + } + if body := rr.Body.String(); !strings.Contains(body, "