Files
CartoLite-Server/backend/internal/httpapi/server.go
T
n30nex 2dbe31dfe9 Port recent CartoLite updates and add worldwide Netgraph (#6)
* Bring CartoLite visuals and recovery to the worldwide server with Netgraph

* Remove remaining country-overlay cleanup references

* Align the CI container identity with the integration checks

* Exercise worldwide grid areas in the large-topology benchmark
2026-09-05 21:38:55 -04:00

277 lines
8.6 KiB
Go

package httpapi
import (
"embed"
"encoding/json"
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/n30nex/cartolite-server/backend/internal/engine"
)
// Docker copies web/dist into this directory before the Go build.
//
//go:embed static/*
var embeddedStatic embed.FS
type Server struct {
engine *engine.Engine
hub *Hub
mqttReady func() bool
static fs.FS
version string
gitSHA string
}
func New(engineState *engine.Engine, hub *Hub, mqttReady func() bool, version, gitSHA string) (*Server, error) {
assets, err := fs.Sub(embeddedStatic, "static")
if err != nil {
return nil, fmt.Errorf("open embedded frontend: %w", err)
}
if _, err := fs.Stat(assets, "index.html"); err != nil {
return nil, fmt.Errorf("embedded frontend has no index.html: %w", err)
}
return &Server{engine: engineState, hub: hub, mqttReady: mqttReady, static: assets, version: version, gitSHA: gitSHA}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.ready)
mux.HandleFunc("GET /api/state", s.state)
mux.HandleFunc("GET /api/events", s.events)
mux.HandleFunc("/", s.frontend)
return securityHeaders(mux)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version, "gitSha": s.gitSHA, "bootId": s.engine.BootID()})
}
func (s *Server) ready(w http.ResponseWriter, _ *http.Request) {
mqttOK := s.mqttReady != nil && s.mqttReady()
checkpointOK := s.engine.CheckpointHealthy()
noDrops := s.engine.Dropped() == 0
queueOK := s.engine.QueueHealthy()
status := http.StatusOK
if !mqttOK || !checkpointOK || !noDrops || !queueOK {
status = http.StatusServiceUnavailable
}
writeJSON(w, status, map[string]any{
"ready": status == http.StatusOK,
"mqtt": mqttOK,
"checkpoint": checkpointOK,
"dropped": s.engine.Dropped(),
"queueDepth": s.engine.QueueDepth(),
"queueHealthy": queueOK,
})
}
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(s.engine.StateJSON())
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
_, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
bootID, after, hasCursor, err := eventCursor(r)
if err != nil {
http.Error(w, "invalid event cursor", http.StatusBadRequest)
return
}
subscription := s.hub.Subscribe(bootID, after, hasCursor)
if !subscription.accepted {
http.Error(w, "too many event streams", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
controller := http.NewResponseController(w)
if subscription.reset {
reset, _ := json.Marshal(map[string]any{"seq": subscription.latest, "bootId": s.engine.BootID()})
_ = writeSSEWithDeadline(w, controller, wireEvent{name: "reset", seq: subscription.latest, data: reset}, true)
return
}
defer subscription.cancel()
hello, _ := json.Marshal(map[string]any{"seq": subscription.latest, "bootId": s.engine.BootID()})
if !writeSSEWithDeadline(w, controller, wireEvent{name: "hello", seq: subscription.latest, data: hello}, false) {
return
}
for _, event := range subscription.replay {
if !writeSSEWithDeadline(w, controller, event, true) {
return
}
}
keepalive := time.NewTicker(15 * time.Second)
defer keepalive.Stop()
for {
select {
case <-r.Context().Done():
return
case event, open := <-subscription.events:
if !open {
return
}
if !writeSSEWithDeadline(w, controller, event, true) {
return
}
case <-keepalive.C:
_ = controller.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
return
}
if err := controller.Flush(); err != nil {
return
}
_ = controller.SetWriteDeadline(time.Time{})
}
}
}
func (s *Server) frontend(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/ws") || strings.HasPrefix(r.URL.Path, "/healthz/") || strings.HasPrefix(r.URL.Path, "/readyz/") {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/netgraph" {
target := "/netgraph/"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusPermanentRedirect)
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "." || name == "" {
name = "index.html"
} else if strings.HasSuffix(r.URL.Path, "/") {
name = path.Join(name, "index.html")
}
body, err := fs.ReadFile(s.static, name)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
return
}
http.Error(w, "frontend unavailable", http.StatusInternalServerError)
return
}
if path.Ext(name) == ".html" {
w.Header().Set("Cache-Control", "no-cache")
} else if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
}
if contentType := staticContentType(name); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
func staticContentType(name string) string {
switch path.Ext(name) {
case ".webmanifest":
return "application/manifest+json"
case ".geojson":
return "application/geo+json"
}
return mime.TypeByExtension(path.Ext(name))
}
func sameOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
return true
}
parsed, err := url.Parse(origin)
return err == nil && parsed.Host != "" && strings.EqualFold(parsed.Host, r.Host)
}
func eventCursor(r *http.Request) (string, uint64, bool, error) {
bootID := strings.TrimSpace(r.URL.Query().Get("bootId"))
after := uint64(0)
hasCursor := false
if value := strings.TrimSpace(r.URL.Query().Get("after")); value != "" {
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return "", 0, false, err
}
after, hasCursor = parsed, true
}
if value := strings.TrimSpace(r.Header.Get("Last-Event-ID")); value != "" {
if parsed, err := strconv.ParseUint(value, 10, 64); err == nil && (!hasCursor || parsed > after) {
after, hasCursor = parsed, true
}
}
return bootID, after, hasCursor, nil
}
func writeSSEWithDeadline(w http.ResponseWriter, controller *http.ResponseController, event wireEvent, includeID bool) bool {
_ = controller.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := writeSSE(w, event, includeID); err != nil {
return false
}
if err := controller.Flush(); err != nil {
return false
}
_ = controller.SetWriteDeadline(time.Time{})
return true
}
func writeSSE(w http.ResponseWriter, event wireEvent, includeID bool) error {
if includeID {
if _, err := fmt.Fprintf(w, "id: %d\n", event.seq); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.name, event.data)
return err
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Permissions-Policy", "accelerometer=(), bluetooth=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), serial=(), usb=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https://*.basemaps.cartocdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.basemaps.cartocdn.com https://tiles.mapterhorn.com; worker-src 'self' blob:; child-src blob:")
next.ServeHTTP(w, r)
})
}