package engineeringdashboard import ( "encoding/json" "fmt" "net" "net/http" "strings" "sync" "time" ) // Server is the local read-only HTTP surface. // Access control is loopback bind + Host/Origin checks (personal local tool; no startup token). type Server struct { Collector *Collector mu sync.Mutex } // NewServer creates a local read-only server. func NewServer(c *Collector) (*Server, error) { return &Server{Collector: c}, nil } // Handler returns the HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealthz) mux.HandleFunc("/api/v1/snapshot", s.handleSnapshot) mux.HandleFunc("/api/v1/doc/", s.handleDoc) mux.HandleFunc("/", s.handleStatic) return s.secure(mux) } func (s *Server) secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "..") || strings.Contains(r.URL.RawPath, "..") || strings.Contains(r.URL.RawQuery, "..") { http.Error(w, "path traversal denied", http.StatusForbidden) return } switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } if host != "127.0.0.1" && host != "localhost" && host != "::1" { http.Error(w, "host not allowed", http.StatusForbidden) return } origin := r.Header.Get("Origin") if origin != "" && !isLocalOrigin(origin) { http.Error(w, "origin not allowed", http.StatusForbidden) return } site := r.Header.Get("Sec-Fetch-Site") if site == "cross-site" { http.Error(w, "cross-site fetch denied", http.StatusForbidden) return } w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) } func isLocalOrigin(origin string) bool { return strings.HasPrefix(origin, "http://127.0.0.1") || strings.HasPrefix(origin, "http://localhost") || strings.HasPrefix(origin, "http://[::1]") } func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) } func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) { ctx := r.Context() snap := s.Collector.Current() if snap == nil || r.URL.Query().Get("refresh") == "1" { var err error snap, err = s.Collector.Refresh(ctx) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } } else { if time.Since(snap.GeneratedAt) > s.Collector.TTL { go func() { _, _ = s.Collector.Refresh(ctx) }() } } etag := `"` + snap.RevisionID + `"` w.Header().Set("ETag", etag) if match := r.Header.Get("If-None-Match"); match == etag { w.WriteHeader(http.StatusNotModified) return } raw, err := MarshalSnapshotJSON(*snap) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _, _ = w.Write(raw) } func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/v1/doc/") id = strings.TrimPrefix(id, "/") if id == "" { http.Error(w, "missing doc id", http.StatusBadRequest) return } if _, err := SafeDocPath(s.Collector.RepoRoot, id); err != nil { http.Error(w, "forbidden", http.StatusForbidden) return } snap := s.Collector.Current() if snap == nil { var err error snap, err = s.Collector.Refresh(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } } for _, d := range snap.Documents { if d.ID == id || d.Path == id { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(d) return } } http.Error(w, "not found", http.StatusNotFound) } func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch path { case "/", "/index.html": b, err := readAsset("assets/index.html") if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(b) case "/assets/app.js": b, err := readAsset("assets/app.js") if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") _, _ = w.Write(b) case "/assets/app.css": b, err := readAsset("assets/app.css") if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") _, _ = w.Write(b) default: http.NotFound(w, r) } } // ListenAndServe binds loopback-only. func ListenAndServe(addr string, h http.Handler) error { host, port, err := net.SplitHostPort(addr) if err != nil { return err } ip := net.ParseIP(host) if ip == nil || !ip.IsLoopback() { return fmt.Errorf("refusing non-loopback bind address: %s", addr) } ln, err := net.Listen("tcp", net.JoinHostPort(host, port)) if err != nil { return err } return http.Serve(ln, h) }