180 lines
5.3 KiB
Go
180 lines
5.3 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
|
|
}
|
|
|
|
// 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)
|
|
}
|