// Package web serves Relata's server-rendered HTML interface. // // Every value interpolated into a page passes through html/template's // contextual auto-escaping. Stored content — titles, excerpts, entity names, // model output — is treated as untrusted text and is never marked safe. There // is deliberately no helper in this package that produces template.HTML from a // stored string, because such a helper is how stored XSS gets introduced. package web import ( "bytes" "embed" "fmt" "html/template" "io/fs" "net/http" "strings" "time" "github.com/andydixon/relata/internal/domain" "github.com/andydixon/relata/internal/observability" ) //go:embed templates/*.html var templateFS embed.FS //go:embed static var staticFS embed.FS // StaticFS exposes the embedded static assets for mounting, rooted at the // assets themselves rather than at the directory containing them. // // The embed directive captures paths as "static/app.css", while the router // strips the "/static/" prefix before looking a file up. Without this the two // cancel out the wrong way and every asset 404s — which the browser reports // not as a missing stylesheet but as corrupted content, because the 404 body // arrives as text/plain under X-Content-Type-Options: nosniff. func StaticFS() fs.FS { sub, err := fs.Sub(staticFS, "static") if err != nil { // Only reachable if the embed directive above stops matching, which is // a build-time mistake rather than a runtime condition. panic("static assets are not embedded: " + err.Error()) } return sub } // Renderer holds the parsed templates. type Renderer struct { templates map[string]*template.Template } // pages are the content templates, each combined with the layout. var pages = []string{ "login.html", "dashboard.html", "items.html", "add_text.html", "upload.html", "item_detail.html", "search.html", "review.html", "entities.html", "entity_detail.html", "jobs.html", "settings.html", "error.html", "neighbourhood.html", "tokens.html", } // publicPages are served to anyone. They use their own layout, which has no // navigation, no workspace context and nothing that reads the database — so a // signed-out visitor cannot be shown stored content by a template mistake. var publicPages = []string{ "public_landing.html", "public_how.html", "public_api.html", } // partials are fragments rendered on their own for HTMX swaps. var partials = []string{ "partial_search_results.html", "partial_relationship.html", "partial_jobs_table.html", "partial_answer.html", } // NewRenderer parses every template at startup, so a template error is a // start-up failure rather than a broken page at request time. func NewRenderer() (*Renderer, error) { r := &Renderer{templates: map[string]*template.Template{}} for _, page := range pages { t, err := template.New("layout.html").Funcs(funcs()).ParseFS(templateFS, "templates/layout.html", "templates/"+page, "templates/partial_*.html") if err != nil { return nil, fmt.Errorf("parse template %s: %w", page, err) } r.templates[page] = t } for _, page := range publicPages { t, err := template.New("public_layout.html").Funcs(funcs()).ParseFS(templateFS, "templates/public_layout.html", "templates/"+page) if err != nil { return nil, fmt.Errorf("parse public template %s: %w", page, err) } r.templates[page] = t } for _, p := range partials { t, err := template.New(p).Funcs(funcs()).ParseFS(templateFS, "templates/"+p) if err != nil { return nil, fmt.Errorf("parse partial %s: %w", p, err) } r.templates[p] = t } return r, nil } // Render writes a full page. // // The page is rendered into a buffer first: a template error midway through // would otherwise leave a half-written page with a 200 status, which looks to a // user like data loss. func (r *Renderer) Render(w http.ResponseWriter, req *http.Request, page string, status int, data any) { t, ok := r.templates[page] if !ok { observability.LoggerFrom(req.Context()).Error("unknown template requested", "template", page) http.Error(w, "Internal error", http.StatusInternalServerError) return } var buf bytes.Buffer if err := t.ExecuteTemplate(&buf, "layout.html", data); err != nil { observability.LoggerFrom(req.Context()).Error("template rendering failed", "template", page, "error", err.Error()) http.Error(w, "Internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(buf.Bytes()) } // RenderPublic writes a page from the public layout. // // It is a separate method rather than a flag on Render so that a public page // cannot accidentally be rendered into the application layout, which expects a // principal and a workspace. func (r *Renderer) RenderPublic(w http.ResponseWriter, req *http.Request, page string, status int, data any) { t, ok := r.templates[page] if !ok { observability.LoggerFrom(req.Context()).Error("unknown public template requested", "template", page) http.Error(w, "Internal error", http.StatusInternalServerError) return } var buf bytes.Buffer if err := t.ExecuteTemplate(&buf, "public_layout.html", data); err != nil { observability.LoggerFrom(req.Context()).Error("public template rendering failed", "template", page, "error", err.Error()) http.Error(w, "Internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, _ = w.Write(buf.Bytes()) } // RenderPartial writes a fragment, for HTMX swaps. func (r *Renderer) RenderPartial(w http.ResponseWriter, req *http.Request, name string, data any) { t, ok := r.templates[name] if !ok { http.Error(w, "Internal error", http.StatusInternalServerError) return } var buf bytes.Buffer if err := t.ExecuteTemplate(&buf, name, data); err != nil { observability.LoggerFrom(req.Context()).Error("partial rendering failed", "template", name, "error", err.Error()) http.Error(w, "Internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(buf.Bytes()) } // funcs are the template helpers. // // None of them returns template.HTML from stored content. `percent` and // `strengthLabel` format numbers; `truncate` shortens text; all output remains // a plain string that the template engine escapes on insertion. func funcs() template.FuncMap { return template.FuncMap{ "percent": func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) }, "score2": func(v float64) string { return fmt.Sprintf("%.2f", v) }, // strengthLabel turns a score into words, so a user is not left to // interpret a bare decimal as if it were a probability. "strengthLabel": func(v float64) string { switch { case v >= 0.75: return "strong" case v >= 0.5: return "moderate" case v >= 0.3: return "weak" default: return "very weak" } }, "provenanceLabel": provenanceLabel, "relTypeLabel": relTypeLabel, "statusLabel": statusLabel, "truncate": func(n int, s string) string { s = strings.Join(strings.Fields(s), " ") if len(s) <= n { return s } for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 { n-- } return s[:n] + "…" }, "datetime": func(t time.Time) string { if t.IsZero() { return "—" } return t.Format("2 Jan 2006, 15:04") }, "date": func(t time.Time) string { if t.IsZero() { return "—" } return t.Format("2 Jan 2006") }, "ago": func(t time.Time) string { if t.IsZero() { return "—" } d := time.Since(t) switch { case d < time.Minute: return "just now" case d < time.Hour: return fmt.Sprintf("%d min ago", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(d.Hours())) default: return fmt.Sprintf("%d days ago", int(d.Hours()/24)) } }, "bytes": func(n int64) string { switch { case n < 1024: return fmt.Sprintf("%d B", n) case n < 1024*1024: return fmt.Sprintf("%.1f KB", float64(n)/1024) default: return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) } }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "gt": func(a, b int) bool { return a > b }, "seq": func(n int) []int { out := make([]int, n) for i := range out { out[i] = i } return out }, "dict": func(values ...any) map[string]any { m := map[string]any{} for i := 0; i+1 < len(values); i += 2 { if k, ok := values[i].(string); ok { m[k] = values[i+1] } } return m }, "relTypes": func() []domain.RelationshipType { return domain.ValidRelationshipTypes }, "sourceTypes": func() []domain.SourceType { return domain.ValidSourceTypes }, "entityTypes": func() []domain.EntityType { return domain.ValidEntityTypes }, } } func provenanceLabel(p domain.Provenance) string { switch p { case domain.ProvExplicit: return "stated by a person" case domain.ProvDeterministic: return "derived from matching evidence" case domain.ProvModelInferred: return "suggested by analysis — not verified" case domain.ProvUserConfirmed: return "confirmed by a person" case domain.ProvUserRejected: return "rejected by a person" } return string(p) } func relTypeLabel(t domain.RelationshipType) string { return strings.ReplaceAll(string(t), "_", " ") } func statusLabel(s domain.ProcessingStatus) string { switch s { case domain.StatusPending: return "Waiting" case domain.StatusParsing: return "Extracting text" case domain.StatusChunking: return "Dividing into sections" case domain.StatusEmbedding: return "Generating embeddings" case domain.StatusAnalysing: return "Extracting entities and claims" case domain.StatusRelating: return "Finding relationships" case domain.StatusReady: return "Ready" case domain.StatusPartial: return "Ready, with warnings" case domain.StatusFailed: return "Failed" case domain.StatusTombstoned: return "Deleted" } return string(s) }