diff options
Diffstat (limited to 'cmd/mirumd/static.go')
| -rw-r--r-- | cmd/mirumd/static.go | 235 | +235 −0 |
1 files changed, 235 insertions, 0 deletions
diff --git a/cmd/mirumd/static.go b/cmd/mirumd/static.go new file mode 100644 --- /dev/null +++ b/cmd/mirumd/static.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/sha512" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" +) + +//go:embed web/*.html +var templateFS embed.FS + +var shellTmpl = template.Must(template.ParseFS(templateFS, "web/shell.html")) + +type assetRef struct { + Href string + Integrity string // "sha384-BASE64" or empty +} + +type pageAssets struct { + CSS []assetRef // <link rel="stylesheet"> + Scripts []assetRef // <script type="module"> + Preloads []assetRef // <link rel="modulepreload"> + Preamble string // inline <script type="module"> body, dev only + + // Full Content-Security-Policy header for this entry, with hash-based script-src. + // Empty in dev mode; renderPage falls back to the global csp in that case. + CSP string +} + +func buildCSP(scripts, preloads, css []assetRef) string { + var sb strings.Builder + sb.WriteString(cspBase) + + writeHashes := func(refs []assetRef) { + for _, r := range refs { + if r.Integrity == "" { + continue + } + sb.WriteString(" '") + sb.WriteString(r.Integrity) + sb.WriteString("'") + } + } + + sb.WriteString("; script-src") + writeHashes(scripts) + writeHashes(preloads) + + // style-src hash-sources for external stylesheets are inconsistently + // supported (Firefox blocks them even when the hash matches). Allow + // same-origin loading and rely on the integrity="" attribute rendered + // into the <link> tag for tamper detection. + sb.WriteString("; style-src 'self'") + writeHashes(css) + + return sb.String() +} + +type assetResolver struct { + assets map[string]pageAssets // keyed by entry name (e.g. "dashboard") +} + +func newAssetResolver() *assetResolver { + if viteDevURL != "" { + return &assetResolver{} + } + + if len(manifestJSON) == 0 { + slog.Warn("vite manifest not found, frontend assets unavailable") + return &assetResolver{assets: map[string]pageAssets{}} + } + + type viteManifestEntry struct { + Name string `json:"name"` + File string `json:"file"` + Src string `json:"src"` + CSS []string `json:"css"` + Imports []string `json:"imports"` + IsEntry bool `json:"isEntry"` + } + + var manifest map[string]viteManifestEntry + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + slog.Error("failed to parse vite manifest", "err", err) + return &assetResolver{assets: map[string]pageAssets{}} + } + + refOf := func(file string) assetRef { + integrity := "" + + data, err := assetsFS.ReadFile("static/" + file) + if err != nil { + slog.Error("asset not found for integrity hash", "file", file, "err", err) + } else { + sum := sha512.Sum384(data) + integrity = "sha384-" + base64.StdEncoding.EncodeToString(sum[:]) + } + + return assetRef{Href: "/" + file, Integrity: integrity} + } + + assets := make(map[string]pageAssets) + for key, entry := range manifest { + if !entry.IsEntry { + continue + } + + // Entry key is e.g. "entries/dashboard.tsx", derive "dashboard". + name := strings.TrimSuffix(path.Base(key), path.Ext(key)) + + seen := make(map[string]bool) + var scripts, preloads, css []assetRef + + var walk func(keys []string) + walk = func(keys []string) { + for _, k := range keys { + if seen[k] { + continue + } + seen[k] = true + chunk, ok := manifest[k] + if !ok { + continue + } + if chunk.IsEntry { + scripts = append(scripts, refOf(chunk.File)) + } else { + preloads = append(preloads, refOf(chunk.File)) + } + for _, c := range chunk.CSS { + if seen[c] { + continue + } + seen[c] = true + css = append(css, refOf(c)) + } + walk(chunk.Imports) + } + } + walk([]string{key}) + + assets[name] = pageAssets{ + Scripts: scripts, + Preloads: preloads, + CSS: css, + CSP: buildCSP(scripts, preloads, css), + } + } + + return &assetResolver{assets: assets} +} + +// resolve returns page assets for the given entry name. Integrity is omitted +// in dev mode because Vite's HMR mutates file contents between requests. +func (ar *assetResolver) resolve(name string) pageAssets { + if viteDevURL != "" { + return pageAssets{ + Scripts: []assetRef{ + {Href: viteDevURL + "/@vite/client"}, + {Href: viteDevURL + "/entries/" + name + ".tsx"}, + }, + // @vitejs/plugin-react requires this preamble to install the + // React Refresh hooks before any component module runs. Vite + // auto-injects it when it serves HTML, so we reproduce it here. + Preamble: `import RefreshRuntime from "` + viteDevURL + `/@react-refresh" +RefreshRuntime.injectIntoGlobalHook(window) +window.$RefreshReg$ = () => {} +window.$RefreshSig$ = () => (type) => type +window.__vite_plugin_react_preamble_installed__ = true`, + } + } + return ar.assets[name] +} + +func (ar *assetResolver) renderPage(w http.ResponseWriter, entry string, status int, data any) { + dataJSON, err := json.Marshal(data) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + assets := ar.resolve(entry) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + if assets.CSP != "" { + w.Header().Set("Content-Security-Policy", assets.CSP) + } + if status != 0 && status != http.StatusOK { + w.WriteHeader(status) + } + + if err := shellTmpl.Execute(w, struct { + DataJSON template.JS + Scripts []assetRef + Preloads []assetRef + CSS []assetRef + Preamble template.JS + }{ + DataJSON: template.JS(dataJSON), + Scripts: assets.Scripts, + Preloads: assets.Preloads, + CSS: assets.CSS, + Preamble: template.JS(assets.Preamble), + }); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + } +} + +func assetsHandler() http.Handler { + sub, err := fs.Sub(assetsFS, "static") + if err != nil { + panic(fmt.Sprintf("static fs: %v", err)) + } + + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + http.NotFound(w, r) + return + } + fileServer.ServeHTTP(w, r) + }) +} |
