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
131 lines
4.5 KiB
Go
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
|
|
// (& < > ' ").
|
|
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)
|
|
}
|
|
}
|