All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
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.
137 lines
5 KiB
Go
137 lines
5 KiB
Go
// Package handlers is the Go port of app.py's own route layer: the
|
|
// interactive tool's SPA shells (app.py's _page) and static-asset serving
|
|
// with the short Cache-Control revalidation window. The server-rendered
|
|
// content pages, robots.txt/sitemap.xml and the IndexNow key file live in
|
|
// internal/content (the content.py port), exactly as the two modules split
|
|
// the work in Python.
|
|
//
|
|
// Wiring (see main.go): content.Handlers.Register takes this package's
|
|
// Static() handler so its lazy IndexNow fallback route can hand non-key
|
|
// requests back to the file server; Register here adds the shells, the
|
|
// static catch-all under BASE, and the bare-BASE 307 redirect.
|
|
package handlers
|
|
|
|
import (
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
)
|
|
|
|
// Options configures the app.py-layer routes.
|
|
type Options struct {
|
|
// Base is config.Base: "" or "/x" (THERMOGRAPH_BASE, normalized).
|
|
Base string
|
|
// StaticDir holds every static asset, including the SPA shell HTML files.
|
|
StaticDir string
|
|
// GoogleVerify / BingVerify feed the search-console <meta> tags injected
|
|
// into the SPA shells (content.head_verify_html read the same env vars;
|
|
// the SSR pages get theirs via the template FuncMap's head_verify — the
|
|
// two copies must keep emitting identical markup).
|
|
GoogleVerify string
|
|
BingVerify string
|
|
// Log defaults to slog.Default().
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Server holds the state the shell/static handlers share. Safe for
|
|
// concurrent use.
|
|
type Server struct {
|
|
base string
|
|
staticDir string
|
|
headVerify template.HTML
|
|
log *slog.Logger
|
|
}
|
|
|
|
// New builds the Server. It does not touch the filesystem — shell files are
|
|
// read lazily on first request, like the Python's _page.
|
|
func New(o Options) *Server {
|
|
log := o.Log
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Server{
|
|
base: o.Base,
|
|
staticDir: o.StaticDir,
|
|
headVerify: headVerifyHTML(o.GoogleVerify, o.BingVerify),
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// Register attaches the SPA-shell routes, the static catch-all and the
|
|
// bare-BASE redirect. Go 1.22 mux precedence (most-specific pattern wins)
|
|
// replaces Starlette's registration-order rule — the explicit content routes
|
|
// registered elsewhere beat the catch-all no matter who registers first.
|
|
// "GET" patterns serve HEAD too, covering the Python's methods=["GET","HEAD"].
|
|
func (s *Server) Register(mux *http.ServeMux) {
|
|
// The SPA shells for the interactive tool (app.py's _page routes).
|
|
// /alerts deliberately serves subscriptions.html.
|
|
for _, sh := range []struct{ path, file string }{
|
|
{"/calendar", "calendar.html"},
|
|
{"/day", "day.html"},
|
|
{"/score", "score.html"},
|
|
{"/compare", "compare.html"},
|
|
{"/legend", "legend.html"},
|
|
{"/alerts", "subscriptions.html"},
|
|
} {
|
|
mux.HandleFunc("GET "+s.base+sh.path, s.shellHandler(sh.file))
|
|
}
|
|
|
|
// Everything else under BASE (app.js, style.css, nav.js, manifest,
|
|
// favicons, …) is a static asset. Patterns must start with "/", so at the
|
|
// root (Base == "") this registers at "/" — the explicit routes still win
|
|
// under the 1.22 precedence rules, replacing Starlette's mount-last order.
|
|
mux.Handle("GET "+s.base+"/", s.Static())
|
|
|
|
if s.base != "" {
|
|
// Starlette's redirect_slashes answered the bare base path with a 307
|
|
// to BASE+"/" (the only slash redirect the Python actually emitted —
|
|
// every other trailing-slash miss fell into the static mount's 404).
|
|
// Registered explicitly so the mux's implicit 301 doesn't apply.
|
|
base := s.base
|
|
mux.HandleFunc("GET "+base, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, base+"/", http.StatusTemporaryRedirect)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Static returns the static-asset handler (also handed to
|
|
// content.Handlers.Register for its lazy IndexNow fallback delegation).
|
|
func (s *Server) Static() http.Handler {
|
|
return http.HandlerFunc(s.serveStatic)
|
|
}
|
|
|
|
// originOf is content.py's _origin, which app.py's _page deliberately reused
|
|
// rather than a simpler duplicate: the shells are reached both directly
|
|
// (Caddy) and through backend's internal proxy fallback, and only this
|
|
// 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 originOf(r *http.Request) string {
|
|
proto := r.Header.Get("X-Forwarded-Proto")
|
|
if proto == "" {
|
|
if r.TLS != nil {
|
|
proto = "https"
|
|
} else {
|
|
proto = "http"
|
|
}
|
|
}
|
|
host := r.Header.Get("X-Forwarded-Host")
|
|
if host == "" {
|
|
host = r.Host // covers both the Host header and the URL netloc
|
|
}
|
|
return proto + "://" + host
|
|
}
|
|
|
|
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
|
|
s.log.Error("internal error", "path", r.URL.Path, "err", err)
|
|
writePlain(w, http.StatusInternalServerError, "Internal Server Error")
|
|
}
|
|
|
|
// writePlain mirrors Starlette's PlainTextResponse: text/plain with charset,
|
|
// nothing appended to the body.
|
|
func writePlain(w http.ResponseWriter, status int, body string) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, body)
|
|
}
|