thermograph/frontend/server/internal/handlers/shells.go

132 lines
4.5 KiB
Go
Raw Normal View History

frontend: rewrite the SSR content service in Go (#28) Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was double-encoded: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00
package handlers
import (
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"thermograph/frontend/internal/render"
)
// headVerifyHTML is content.head_verify_html for the SPA shells: the
// search-engine ownership-verification <meta> tags, from env (empty when
// unset). The SSR pages carry the identical markup via the template FuncMap's
// head_verify entry (internal/content owns that copy); the shells need it
// here because their HTML never passes through the template engine.
// template.HTMLEscapeString emits the same five entities markupsafe escaped
// (&amp; &lt; &gt; &#39; &#34;).
func headVerifyHTML(google, bing string) template.HTML {
var metas []string
if google != "" {
metas = append(metas, `<meta name="google-site-verification" content="`+
template.HTMLEscapeString(google)+`">`)
}
if bing != "" {
metas = append(metas, `<meta name="msvalidate.01" content="`+
template.HTMLEscapeString(bing)+`">`)
}
return template.HTML(strings.Join(metas, "\n "))
}
// shellMemoMax bounds the per-origin memo. The origin is client-controlled
// (Host/X-Forwarded-Host), so an unbounded map is the same cheap
// memory-exhaustion vector api_client.py's LRU cap closed — the Python's
// _by_origin dict had no bound (one canonical origin in every real topology
// made it moot); a flat cap keeps that property for legit traffic and just
// resets the memo under abuse instead of growing forever.
const shellMemoMax = 1024
// shell is one SPA-shell route's state — the Go port of app.py's _page():
// the file (and the verification <meta> tags, both constant for the process's
// lifetime) is read and prepped once, not on every request; only the
// __ORIGIN__ substitution actually varies per request, and even that repeats
// across requests (one canonical origin in the common topology), so the
// substituted HTML + its ETag are memoized per origin instead of
// re-read-and-re-sha1'd every time.
type shell struct {
srv *Server
file string
mu sync.Mutex
template string // "" = not loaded yet; a failed read retries next request
byOrigin map[string]shellEntry
}
type shellEntry struct {
html string
etag string
}
// shellHandler builds the handler for one SPA-shell HTML page (the
// interactive tool's calendar/day/score/compare/legend/alerts views). It
// serves the file with its __ORIGIN__ placeholder (the link-preview/Open
// Graph tags) filled in — preview crawlers need absolute URLs, and the host
// differs between LAN and prod. originOf (not a simpler duplicate) matters
// here: this route is reached both directly (Caddy) and through backend's
// proxy fallback, and only that version prefers X-Forwarded-Host over Host —
// required for the proxied case to resolve the real browser-facing host
// instead of this internal hop's own address.
func (s *Server) shellHandler(file string) http.HandlerFunc {
sh := &shell{srv: s, file: file, byOrigin: make(map[string]shellEntry)}
return sh.serve
}
// load reads and preps the shell file; the caller holds sh.mu. Search-engine
// verification <meta> tags (same source as the SSR content pages) are folded
// in here, so the interactive tool's own pages carry them too.
func (sh *shell) load() (string, error) {
if sh.template != "" {
return sh.template, nil
}
raw, err := os.ReadFile(filepath.Join(sh.srv.staticDir, sh.file))
if err != nil {
return "", err
}
html := string(raw)
if verify := string(sh.srv.headVerify); verify != "" {
html = strings.Replace(html, "<head>", "<head>\n "+verify, 1)
}
sh.template = html
return html, nil
}
func (sh *shell) serve(w http.ResponseWriter, r *http.Request) {
originPrefix := originOf(r) + sh.srv.base
sh.mu.Lock()
ent, ok := sh.byOrigin[originPrefix]
if !ok {
tpl, err := sh.load()
if err != nil {
sh.mu.Unlock()
sh.srv.serverError(w, r, err)
return
}
html := strings.ReplaceAll(tpl, "__ORIGIN__", originPrefix)
ent = shellEntry{html: html, etag: render.ETag([]byte(html))}
if len(sh.byOrigin) >= shellMemoMax {
clear(sh.byOrigin)
}
sh.byOrigin[originPrefix] = ent
}
sh.mu.Unlock()
// app.py's _page compared If-None-Match to the ETag verbatim (no
// comma-splitting) — NotModifiedExact keeps that precise behavior.
if render.NotModifiedExact(r.Header.Get("If-None-Match"), ent.etag) {
w.Header().Set("ETag", ent.etag)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", ent.etag)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, ent.html)
}
}