100 lines
3.3 KiB
Go
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 && npm ci && 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"
|
|
}
|