thermograph/frontend/server/internal/handlers/static.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

62 lines
2.5 KiB
Go

package handlers
import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// staticCacheControl mirrors app.py's _STATIC_CACHE_CONTROL: static assets
// aren't content-hashed in their filenames, so this has to stay short — it's
// a revalidation window, not a long-lived immutable cache. Still saves a full
// body re-transfer (the ETag/Last-Modified pair otherwise forces a
// conditional GET on every navigation) for anything fetched again within it.
// Stamped on every static file response, including 304s.
const staticCacheControl = "public, max-age=300"
// serveStatic is the catch-all under BASE — the Go analogue of app.py's
// _CachedStaticFiles mount, matching Starlette's StaticFiles semantics rather
// than http.FileServer's (which would 301 */index.html to the directory and
// render directory listings; Starlette 404s both —
// tests/unit/test_pages.py::test_old_static_index_is_gone pins the index.html
// case, keeping it from lingering as indexable duplicate content behind the
// server-rendered homepage).
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, s.base)
// Trailing-slash paths never name a file; Starlette 404'd them (the
// mount swallowed everything under BASE, so redirect_slashes never ran
// for them).
if strings.HasSuffix(rel, "/") {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
// Rooted Clean cannot escape the static dir ("/../x" cleans to "/x");
// the mux has already cleaned the request path, this is belt and braces
// for handlers invoked directly (the content package's lazy IndexNow
// route delegates here without re-cleaning).
rel = path.Clean("/" + strings.TrimPrefix(rel, "/"))
full := filepath.Join(s.staticDir, filepath.FromSlash(strings.TrimPrefix(rel, "/")))
info, err := os.Stat(full)
if err != nil || info.IsDir() {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
f, err := os.Open(full)
if err != nil {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
defer f.Close()
w.Header().Set("Cache-Control", staticCacheControl)
// Starlette's StaticFiles stamped a stat-derived ETag (mtime+size hash);
// same recipe here so If-None-Match revalidation keeps costing an empty
// 304 instead of a body re-transfer. Headers set before ServeContent
// survive on its 304/206 paths, Cache-Control included.
w.Header().Set("ETag", fmt.Sprintf("\"%x-%x\"", info.ModTime().UnixNano(), info.Size()))
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}