thermograph/frontend/server/internal/handlers/shells.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

131 lines
4.5 KiB
Go

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)
}
}