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) } 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, " 404 — Not Found - + -
-
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 +
-
+
+ + 404 — resource not found +
- - - Served by static-web — BackendStack21 +
+
// 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/public/index.html b/public/index.html index c6a3145..2d1891d 100644 --- a/public/index.html +++ b/public/index.html @@ -3,252 +3,78 @@ - static-web — BackendStack21 - - - -
-
Running
+
+
// replace this page
+
+ step 1 + drop your files into ./public/ +
+
+ step 2 + your index.html replaces this page +
+
+ config + edit config.toml to customize +
+
-

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. -

+
+
// 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
+
-
-
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
-
+
-
+
+ # + docs & source → + github.com/BackendStack21/static-web +
- + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..5cac634 --- /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: 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; } +}