// Package web serves the web UI, compiled into the binary. // // There is no build step: the files under static/ are what the browser gets. // The page talks to the server's own /api over the same origin, signed in with // the session cookie from POST /api/login. package web import ( "bytes" "crypto/sha256" "embed" "encoding/base64" "errors" "io/fs" "net/http" "path" "strings" "time" ) //go:embed static var files embed.FS // asset is one embedded file, with its validator computed once at startup. type asset struct { body []byte etag string ctype string cache string } // Handler serves the embedded site. A path without a file extension that // matches no file gets index.html, so a deep link such as /incidents/42 — the // target of a notification tap — survives a reload; the page reads the path // and renders the right view. A missing file with an extension is a real 404. func Handler() (http.Handler, error) { root, err := fs.Sub(files, "static") if err != nil { return nil, err } assets := make(map[string]*asset) err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } b, err := fs.ReadFile(root, p) if err != nil { return err } sum := sha256.Sum256(b) assets["/"+p] = &asset{ body: b, etag: `"` + base64.RawURLEncoding.EncodeToString(sum[:16]) + `"`, ctype: contentType(p), cache: cacheControl(p), } return nil }) if err != nil { return nil, err } index, ok := assets["/index.html"] if !ok { return nil, errors.New("web: static/index.html is missing") } // embed.FS reports a zero ModTime, so http.FileServerFS would emit no // validator and every asset would be refetched in full on every load. // Hence the ETag above and ServeContent below, with a zero time that // suppresses Last-Modified. var noTime time.Time return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } p := path.Clean(r.URL.Path) f, ok := assets[p] if !ok { if path.Ext(p) != "" { http.NotFound(w, r) return } f = index } w.Header().Set("Content-Type", f.ctype) w.Header().Set("Cache-Control", f.cache) w.Header().Set("ETag", f.etag) // The page loads nothing from anywhere else, so the policy can say so // outright rather than carve out exceptions. w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "+ "connect-src 'self'; manifest-src 'self'; form-action 'self'; "+ "frame-ancestors 'none'; base-uri 'none'") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") http.ServeContent(w, r, "", noTime, bytes.NewReader(f.body)) }), nil } func contentType(p string) string { switch path.Ext(p) { case ".html": return "text/html; charset=utf-8" case ".css": return "text/css; charset=utf-8" case ".js": return "text/javascript; charset=utf-8" case ".svg": return "image/svg+xml" case ".png": return "image/png" case ".webmanifest": return "application/manifest+json" default: return "application/octet-stream" } } // cacheControl keeps index.html revalidating on every load, because it names // the current asset paths. Assets carry an ETag, so a five-minute window costs // one conditional request after a deploy rather than a stale page. func cacheControl(p string) string { if strings.HasSuffix(p, ".html") { return "no-cache" } return "public, max-age=300" }