NextNVR/server.go

183 lines
5.4 KiB
Go

// 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
onReload func() // called when config is saved via API
}
// 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() (*http.Server, error) {
viewerAddr := s.config.Server.BindHost + s.config.Server.ViewerPort
if viewerAddr == "" || viewerAddr == ":0" || viewerAddr == ":" {
return nil, nil
}
viewerMux := http.NewServeMux()
viewerMux.Handle("/", s.middleware(s.viewerOnly(s.mux)))
v := &http.Server{Addr: viewerAddr, Handler: viewerMux}
log.Printf("Viewer server listening on %s (live wall only, no auth)", viewerAddr)
return v, v.ListenAndServe()
}
// 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/config/reload", s.handleConfigReload)
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)
}