package httpserver import ( "context" "embed" "encoding/json" "errors" "html/template" "io/fs" "log" "net/http" "strings" "time" "kubeviz/internal/config" "kubeviz/internal/session" ) const sessionCookieName = "kubeviz_session" type Server struct { cfg config.Config store *session.Store templates *template.Template mux *http.ServeMux } func New(cfg config.Config) (*Server, error) { tpls, err := template.ParseFS(templateFS, "ui/templates/*.html") if err != nil { return nil, err } s := &Server{ cfg: cfg, store: session.NewStore(cfg.SessionTTL), templates: tpls, mux: http.NewServeMux(), } s.routes() return s, nil } func (s *Server) routes() { s.mux.Handle("/", s.middleware(http.HandlerFunc(s.handleIndex))) s.mux.Handle("/api/manifests/parse", s.middleware(http.HandlerFunc(s.handleParseManifests))) s.mux.Handle("/api/helm/render", s.middleware(http.HandlerFunc(s.handleHelmRender))) s.mux.Handle("/api/git/import", s.middleware(http.HandlerFunc(s.handleGitImport))) s.mux.Handle("/api/graph", s.middleware(http.HandlerFunc(s.handleGraph))) s.mux.Handle("/api/diff", s.middleware(http.HandlerFunc(s.handleDiff))) s.mux.Handle("/api/resources/", s.middleware(http.HandlerFunc(s.handleResource))) s.mux.Handle("/api/export/svg", s.middleware(http.HandlerFunc(s.handleExportSVG))) s.mux.Handle("/api/export/png", s.middleware(http.HandlerFunc(s.handleExportPNG))) s.mux.Handle("/api/session/clear", s.middleware(http.HandlerFunc(s.handleClearSession))) staticSub, _ := fs.Sub(staticFS, "ui/static") s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) } func (s *Server) Handler() http.Handler { return s.mux } func (s *Server) Shutdown(ctx context.Context) { s.store.Stop() _ = ctx } func (s *Server) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() r = r.WithContext(ctx) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") if s.cfg.AppCSPEnabled { w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self'; script-src-elem 'self'; style-src 'self' 'unsafe-inline'; style-src-elem 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:;") } if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } next.ServeHTTP(w, r) }) } func (s *Server) sessionID(w http.ResponseWriter, r *http.Request) (string, error) { cookie, err := r.Cookie(sessionCookieName) if err == nil && cookie.Value != "" { return cookie.Value, nil } if !errors.Is(err, http.ErrNoCookie) && err != nil { return "", err } sid, err := s.store.NewSessionID() if err != nil { return "", err } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sid, Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: int(s.cfg.SessionTTL.Seconds()), }) return sid, nil } func parseRelations(raw string) map[string]bool { return parseCSVSet(raw) } func parseCSVSet(raw string) map[string]bool { if raw == "" { return nil } out := map[string]bool{} for _, item := range strings.Split(raw, ",") { trimmed := strings.TrimSpace(item) if trimmed == "" { continue } out[trimmed] = true } return out } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { log.Printf("failed writing JSON: %v", err) } } func subFS(fsys embed.FS, dir string) fs.FS { sub, err := fs.Sub(fsys, dir) if err != nil { panic(err) } return sub }