Files
fancy-dns/internal/web/web.go
T
2026-09-27 11:20:34 +00:00

100 lines
3.3 KiB
Go

// Package web serves the embedded admin console as a single-page app.
//
// The assets are compiled into the binary, so a deployment is one file plus a
// config; there is no node process in production and no CDN dependency. When
// the assets are absent (a `go build` without `npm run build`) the server says
// so plainly instead of returning a confusing 404.
package web
import (
"embed"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
)
//go:embed all:dist
var dist embed.FS
// Available reports whether compiled assets are present in this binary.
func Available() bool {
_, err := dist.Open("dist/index.html")
return err == nil
}
// Handler serves the console, falling back to index.html for client-side
// routes so a deep link like /logs works on a hard refresh.
func Handler(basePath string) (http.Handler, error) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, fmt.Errorf("web: open assets: %w", err)
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(missingAssets))
}), nil
}
files := http.FileServer(http.FS(sub))
prefix := "/"
if basePath != "" && basePath != "/" {
prefix = "/" + strings.Trim(basePath, "/") + "/"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Hashed asset filenames are immutable, so they can be cached hard.
if strings.HasPrefix(r.URL.Path, prefix+"assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
clean := strings.TrimPrefix(r.URL.Path, strings.TrimSuffix(prefix, "/"))
clean = strings.TrimPrefix(clean, "/")
if clean == "" {
clean = "index.html"
}
if _, err := fs.Stat(sub, clean); err != nil {
// Unknown path: hand it to the SPA router.
b, rerr := fs.ReadFile(sub, "index.html")
if rerr != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return
}
files.ServeHTTP(w, r)
}), nil
}
const missingAssets = `<!doctype html>
<html><head><meta charset="utf-8"><title>fancy-dns</title>
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#0f172a;background:#f8fafc}
code{background:#e2e8f0;padding:.15em .4em;border-radius:.25rem}</style></head>
<body><h1>fancy-dns</h1>
<p>The admin API is running, but the web console was not built into this binary.</p>
<p>Build it with <code>make web</code> (or <code>cd web &amp;&amp; npm ci &amp;&amp; npm run build</code>), then rebuild the binary.</p>
<p>The JSON API is available under <code>/api</code>.</p></body></html>`
// Version is the asset build stamp, exposed in the UI footer.
func Version() string {
b, err := dist.ReadFile("dist/.build-version")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// CacheControlMaxAge is the immutable-asset max age in seconds.
const CacheControlMaxAge = 31536000
// maxAgeHeader builds the Cache-Control value for a hashed asset.
func maxAgeHeader(seconds int) string {
return "public, max-age=" + strconv.Itoa(seconds) + ", immutable"
}