// NextNVR — MIT License // Copyright (c) 2026 NextNVR Contributors // SPDX-License-Identifier: MIT // Serves the embedded SPA and API endpoints. package main import ( "embed" "encoding/json" "io/fs" "log" "net/http" "net/http/httputil" "net/url" "strings" "sync" ) //go:embed public/* var publicFiles embed.FS // Server wraps the HTTP server and application state. type Server struct { config Config http *http.Server mux *http.ServeMux appConfig *Config mu sync.RWMutex sessions *SessionStore } // NewServer creates and configures the HTTP server. func NewServer(cfg Config) (*Server, error) { s := &Server{ config: cfg, appConfig: &cfg, mux: http.NewServeMux(), sessions: NewSessionStore(), } s.registerRoutes() // Wrap with auth middleware for port 8080. authHandler := s.authRequired(s.mux) s.http = &http.Server{ Addr: cfg.Server.BindHost + cfg.Server.Port, Handler: authHandler, } return s, nil } // ListenAndServe starts the HTTP server. func (s *Server) ListenAndServe() error { log.Printf("HTTP server listening on %s", s.http.Addr) return s.http.ListenAndServe() } // StartViewerServer launches the viewer-only server on the configured port. func (s *Server) StartViewerServer() error { viewerAddr := s.config.Server.BindHost + s.config.Server.ViewerPort if viewerAddr == "" || viewerAddr == ":0" || viewerAddr == ":" { return nil // viewer port not configured } viewerMux := http.NewServeMux() viewerMux.Handle("/", s.middleware(s.viewerOnly(s.mux))) log.Printf("Viewer server listening on %s (live wall only, no auth)", viewerAddr) return http.ListenAndServe(viewerAddr, viewerMux) } // Close shuts down the HTTP server. func (s *Server) Close() { if s.http != nil { s.http.Close() } } // go2rtcProxy returns a reverse proxy to go2rtc on port 1984. // Used to avoid mixed-content blocking when NextNVR is behind HTTPS. func (s *Server) go2rtcProxy() http.Handler { target, _ := url.Parse("http://127.0.0.1:1984") return httputil.NewSingleHostReverseProxy(target) } // injectRole injects the role into the HTML meta tag for the SPA. func (s *Server) injectRole(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { role := r.Header.Get("X-NextNVR-Role") if role == "" { if cookie, err := r.Cookie("session"); err == nil { if sess := s.sessions.Get(cookie.Value); sess != nil { role = sess.Role } } } if (r.URL.Path == "/" || r.URL.Path == "/index.html") && role != "" { data, err := publicFiles.ReadFile("public/index.html") if err == nil { content := strings.Replace(string(data), `content=""`, `content="`+role+`"`, 1) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(content)) return } } next.ServeHTTP(w, r) }) } // registerRoutes sets up all API and static file routes. func (s *Server) registerRoutes() { // API endpoints. s.mux.HandleFunc("/api/cameras", s.handleCameras) s.mux.HandleFunc("/api/cameras/", s.handleCameraByID) s.mux.HandleFunc("/api/config", s.handleConfig) s.mux.HandleFunc("/api/scan", s.handleScan) s.mux.HandleFunc("/api/status", s.handleStatus) s.mux.HandleFunc("/api/recordings", s.handleRecordings) // Auth endpoints. s.mux.HandleFunc("/api/login", s.handleLogin) s.mux.HandleFunc("/api/logout", s.handleLogout) s.mux.HandleFunc("/api/session", s.handleSession) // Login page (served from embedded public dir). s.mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { data, _ := publicFiles.ReadFile("public/login.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(data) }) s.mux.HandleFunc("/stream/", s.handleStream) // go2rtc reverse proxy — serves the go2rtc web UI and API through NextNVR. // This avoids mixed-content blocking when NextNVR is behind HTTPS. s.mux.Handle("/go2rtc/", http.StripPrefix("/go2rtc", s.go2rtcProxy())) // Static file server for recordings (actual video files on disk). s.mux.Handle("/recordings/", http.StripPrefix("/recordings/", http.FileServer(http.Dir(s.config.Storage.RecordingsPath)))) // Static file server for embedded SPA (with role injection). publicFS, _ := fs.Sub(publicFiles, "public") s.mux.Handle("/", s.injectRole(http.FileServer(http.FS(publicFS)))) } // middleware wraps handlers with CORS, no-cache, and logging. func (s *Server) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") // Prevent caching of static files during development. w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } // API logging. if strings.HasPrefix(r.URL.Path, "/api/") { log.Printf("%s %s", r.Method, r.URL.Path) } next.ServeHTTP(w, r) }) } // jsonResponse writes a JSON response with the given status code. func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(resp) }