// Package web serves the two HTML surfaces: a landing page on the // client-facing port and a read-only dashboard on the admin one. // // Server-rendered templates with one stylesheet, embedded in the binary. The // single-binary goal rules out a JavaScript toolchain — a build step in CI, a // node runtime on the runner, and megabytes of bundle — and nothing on either // page needs one. The dashboard refreshes by reloading. // // The split between the two pages is a security boundary, not a layout choice. // The landing page is served to anyone who can reach the address, so it // contains no data at all: it says how to connect, not what is here. The // dashboard shows the cluster and belongs on the admin listener, which is bound // to loopback — see Dashboard. package web import ( "embed" "fmt" "html/template" "io/fs" "net/http" "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) //go:embed templates var files embed.FS // staticFiles is a separate embed from the templates on purpose. Static() serves // this whole tree, and a tree that also held the templates would serve those // too — harmless today and the kind of thing that stops being harmless. // //go:embed static var staticFiles embed.FS var funcs = template.FuncMap{ "join": func(v []string) string { if len(v) == 0 { return "—" } return strings.Join(v, ", ") }, "duration": func(sec int64) string { return time.Duration(sec * int64(time.Second)).Round(time.Second).String() }, "ago": func(t time.Time) string { if t.IsZero() { return "never" } return time.Since(t).Round(time.Second).String() + " ago" }, // Spend is stored in micro-units so accumulation is exact; it is shown in // whole units because that is the number an operator set a budget in. "units": func(micros int64) string { return fmt.Sprintf("%.2f", float64(micros)/1e6) }, "date": func(t time.Time) string { if t.IsZero() { return "—" } return t.Format("2006-01-02") }, // A key's state as the profile page shows it, computed here rather than in // the template so "expired" cannot quietly become "active" when nobody // updates the condition. "state": func(k store.Key) string { switch { case !k.RevokedAt.IsZero(): return "revoked" case !k.ExpiresAt.IsZero() && !time.Now().Before(k.ExpiresAt): return "expired" default: return "active" } }, } var tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(files, "templates/*.html")) // Static serves the embedded assets, mounted on both listeners because both // pages use them. // // Over its own embed, which holds nothing but assets. A file server pointed at // the tree that also carries the templates would serve those as well. func Static() http.Handler { sub, err := fs.Sub(staticFiles, "static") if err != nil { panic("web: the static assets are missing from the binary: " + err.Error()) } fileServer := http.StripPrefix("/_static/", http.FileServerFS(noDirs{sub})) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Everything here is embedded, so it changes only when the binary does // and can be cached hard. The fonts in particular are 300 KB that // should be fetched once. w.Header().Set("Cache-Control", "public, max-age=86400") fileServer.ServeHTTP(w, r) }) } // noDirs hides directories from the file server. // // Listing them would publish the shape of the asset tree, which is not secret // and not anybody's business either. Nothing on any page links to a directory, // so the only requests for one are from somebody looking around. type noDirs struct{ fs.FS } func (n noDirs) Open(name string) (fs.File, error) { f, err := n.FS.Open(name) if err != nil { return nil, err } info, err := f.Stat() if err != nil { _ = f.Close() return nil, err } if info.IsDir() { _ = f.Close() return nil, fs.ErrNotExist } return f, nil } func render(w http.ResponseWriter, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, name, data); err != nil { // The status is already written by then, so there is nothing honest to // send; the page simply ends short. http.Error(w, "template error", http.StatusInternalServerError) } }