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 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 // (& < > ' "). func headVerifyHTML(google, bing string) template.HTML { var metas []string if google != "" { metas = append(metas, ``) } if bing != "" { metas = append(metas, ``) } 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 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 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, "", "\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) } }