CI / test (push) Successful in 1m20s
Replies are plain text, so a model asked about an integral answers with \int_0^1 x^2 dx and the reader gets the source. KaTeX is vendored into the binary rather than loaded from a CDN. This gateway makes no outbound requests of its own and is expected to run where it could not make one; a chat page that silently loses its maths on an air-gapped cluster would be worse than one that never had it. 600 KB of JS, CSS and WOFF2 fonts, which the stripped binary absorbs without changing its rounded size. The ordering is the safety argument, and there is a test for it rather than a comment alone. The reply is put in place with textContent exactly as before, and only then walked for maths: renderMathInElement replaces the spans it recognises, so nothing the model wrote is ever parsed as HTML and the markup that appears is KaTeX's. trust stays false — KaTeX's default, and what keeps \href and \includegraphics out of a reply — and throwOnError is off so one malformed expression does not take the rest with it. Rendered once the reply is complete. Typesetting each chunk would try to set half an expression on every token and flicker through the whole answer. The static handler now serves a small embedded asset tree rather than one file, so it moved to its own embed: a file server over the tree that also carries the templates would serve those too. Directory listings are refused — nothing links to one, so the only requests are from somebody looking around. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
135 lines
4.2 KiB
Go
135 lines
4.2 KiB
Go
// 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)
|
|
}
|
|
}
|