feat: two-port auth — 8080 with login, 8090 viewer-only (live wall)

This commit is contained in:
Claus Lohmar 2026-08-06 12:56:48 +01:00
parent d4eada42d8
commit eaa612278d
11 changed files with 487 additions and 17 deletions

256
auth.go Normal file
View file

@ -0,0 +1,256 @@
// NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT
//
// auth.go — Session-based authentication with bcrypt password hashing.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionStore holds authenticated sessions in memory.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
// Session represents an authenticated user session.
type Session struct {
Username string
Role string // "master" or "viewer"
Expires time.Time
}
// NewSessionStore creates a new in-memory session store.
func NewSessionStore() *SessionStore {
s := &SessionStore{sessions: make(map[string]*Session)}
go s.cleanupLoop()
return s
}
// Create generates a new session token and stores it.
func (s *SessionStore) Create(username, role string) string {
s.mu.Lock()
defer s.mu.Unlock()
token := generateToken()
s.sessions[token] = &Session{
Username: username,
Role: role,
Expires: time.Now().Add(24 * time.Hour),
}
return token
}
// Get returns a session by token, or nil if expired/missing.
func (s *SessionStore) Get(token string) *Session {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[token]
if !ok || time.Now().After(sess.Expires) {
return nil
}
return sess
}
// Delete removes a session (logout).
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
// cleanupLoop removes expired sessions every hour.
func (s *SessionStore) cleanupLoop() {
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
s.mu.Lock()
now := time.Now()
for token, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, token)
}
}
s.mu.Unlock()
}
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// ── Handlers ──
// handleLogin processes POST /api/login.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid request"})
return
}
auth := s.appConfig.Auth
// Check master credentials.
if auth.Master.Username != "" && req.Username == auth.Master.Username {
if err := bcrypt.CompareHashAndPassword([]byte(auth.Master.Password), []byte(req.Password)); err == nil {
token := s.sessions.Create(req.Username, "master")
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "master"}})
return
}
}
// Check viewer credentials.
if auth.Viewer.Enabled && auth.Viewer.Username != "" && req.Username == auth.Viewer.Username {
if err := bcrypt.CompareHashAndPassword([]byte(auth.Viewer.Password), []byte(req.Password)); err == nil {
token := s.sessions.Create(req.Username, "viewer")
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "viewer"}})
return
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "invalid credentials"})
}
// handleLogout processes POST /api/logout.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session"); err == nil {
s.sessions.Delete(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "logged out"})
}
// handleSession returns the current session info.
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}})
return
}
sess := s.sessions.Get(cookie.Value)
if sess == nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": sess.Role, "username": sess.Username}})
}
// ── Middleware ──
// authRequired redirects to /login if no valid session.
func (s *Server) authRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow login page and API, static assets, and recordings.
path := r.URL.Path
if path == "/login" || path == "/login.html" ||
strings.HasPrefix(path, "/api/login") ||
strings.HasPrefix(path, "/api/logout") ||
strings.HasPrefix(path, "/api/session") ||
strings.HasPrefix(path, "/recordings/") ||
strings.HasPrefix(path, "/go2rtc/") ||
strings.HasPrefix(path, "/stream/") {
next.ServeHTTP(w, r)
return
}
// If auth is disabled, allow all.
if !s.appConfig.Auth.Enabled {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie("session")
if err != nil {
// Redirect API calls with 401, page requests to /login.
if strings.HasPrefix(path, "/api/") {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "authentication required"})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
sess := s.sessions.Get(cookie.Value)
if sess == nil {
if strings.HasPrefix(path, "/api/") {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "session expired"})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
// Inject role into request context.
r.Header.Set("X-NextNVR-Role", sess.Role)
next.ServeHTTP(w, r)
})
}
// viewerOnly restricts access to live wall only (port 8090).
func (s *Server) viewerOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Allow live wall assets and recordings.
if path == "/" || path == "/index.html" ||
strings.HasPrefix(path, "/app.js") ||
strings.HasPrefix(path, "/style.css") ||
strings.HasPrefix(path, "/recordings/") ||
strings.HasPrefix(path, "/go2rtc/") ||
strings.HasPrefix(path, "/stream/") ||
strings.HasPrefix(path, "/api/cameras") ||
strings.HasPrefix(path, "/api/status") {
r.Header.Set("X-NextNVR-Role", "viewer")
next.ServeHTTP(w, r)
return
}
// Block everything else (settings, playback API, config).
jsonResponse(w, http.StatusForbidden, APIResponse{Error: "viewer access only"})
})
}
// HashPassword returns a bcrypt hash of the password.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}

View file

@ -17,6 +17,7 @@ import (
type Config struct { type Config struct {
Server ServerConfig `yaml:"server" json:"server"` Server ServerConfig `yaml:"server" json:"server"`
Storage StorageConfig `yaml:"storage" json:"storage"` Storage StorageConfig `yaml:"storage" json:"storage"`
Auth AuthConfig `yaml:"auth" json:"auth"`
Cameras []CameraConfig `yaml:"cameras" json:"cameras"` Cameras []CameraConfig `yaml:"cameras" json:"cameras"`
Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"` Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"`
} }
@ -25,6 +26,7 @@ type Config struct {
type ServerConfig struct { type ServerConfig struct {
Port string `yaml:"port" json:"port"` Port string `yaml:"port" json:"port"`
BindHost string `yaml:"bind_host" json:"bind_host"` BindHost string `yaml:"bind_host" json:"bind_host"`
ViewerPort string `yaml:"viewer_port" json:"viewer_port"`
} }
// StorageConfig holds recording and retention settings. // StorageConfig holds recording and retention settings.
@ -56,6 +58,20 @@ type Go2RTCConfig struct {
Binary string `yaml:"binary" json:"binary"` Binary string `yaml:"binary" json:"binary"`
} }
// AuthConfig holds authentication settings.
type AuthConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Master UserConfig `yaml:"master" json:"master"`
Viewer UserConfig `yaml:"viewer" json:"viewer"`
}
// UserConfig holds a single user's credentials.
type UserConfig struct {
Username string `yaml:"username" json:"username"`
Password string `yaml:"password" json:"password"` // bcrypt hash
Enabled bool `yaml:"enabled" json:"enabled"`
}
// APIResponse wraps all JSON API responses. // APIResponse wraps all JSON API responses.
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@ -77,6 +93,7 @@ func DefaultConfig() Config {
Server: ServerConfig{ Server: ServerConfig{
Port: ":8080", Port: ":8080",
BindHost: "0.0.0.0", BindHost: "0.0.0.0",
ViewerPort: ":8090",
}, },
Storage: StorageConfig{ Storage: StorageConfig{
RecordingsPath: "/mnt/recordings", RecordingsPath: "/mnt/recordings",

View file

@ -4,12 +4,23 @@
server: server:
port: ":8080" port: ":8080"
bind_host: "0.0.0.0" bind_host: "0.0.0.0"
viewer_port: ":8090"
storage: storage:
recordings_path: "/mnt/recordings" recordings_path: "/mnt/recordings"
retention_days: 7 retention_days: 7
cleanup_interval_mins: 60 cleanup_interval_mins: 60
auth:
enabled: false
master:
username: ""
password: ""
viewer:
enabled: true
username: ""
password: ""
go2rtc: go2rtc:
enabled: false enabled: false
port: ":1984" port: ":1984"

7
go.mod
View file

@ -1,5 +1,8 @@
module github.com/cclohmar/NextNVR module github.com/cclohmar/NextNVR
go 1.24.5 go 1.25.0
require gopkg.in/yaml.v3 v3.0.1 require (
golang.org/x/crypto v0.54.0
gopkg.in/yaml.v3 v3.0.1
)

2
go.sum
View file

@ -1,3 +1,5 @@
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

21
go2rtc.yaml Normal file
View file

@ -0,0 +1,21 @@
# go2rtc configuration — generated by NextNVR
api:
listen: ":1984"
streams:
cam_201_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.201:554/Streaming/Channels/102
cam_201_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.201:554/Streaming/Channels/101
cam_202_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.202:554/Streaming/Channels/102
cam_202_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.202:554/Streaming/Channels/101
cam_203_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.203:554/Streaming/Channels/102
cam_203_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.203:554/Streaming/Channels/101
cam_205_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.205:554/Streaming/Channels/102
cam_205_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.205:554/Streaming/Channels/101
cam_206_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.206:554/Streaming/Channels/102
cam_206_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.206:554/Streaming/Channels/101
cam_207_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.207:554/Streaming/Channels/102
cam_207_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.207:554/Streaming/Channels/101
cam_208_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.208:554/Streaming/Channels/102
cam_208_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.208:554/Streaming/Channels/101
cam_209_sub: rtsp://admin:6HtAW3UkkNxC@192.168.1.209:554/Streaming/Channels/102
cam_209_main: rtsp://admin:6HtAW3UkkNxC@192.168.1.209:554/Streaming/Channels/101

22
main.go
View file

@ -75,12 +75,22 @@ func main() {
go cln.Start() go cln.Start()
app.cleaner = cln app.cleaner = cln
// Start the HTTP server (blocks). // Start the HTTP server (main port, with auth).
app.server, _ = NewServer(cfg)
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- app.StartServer() errCh <- app.server.ListenAndServe()
}() }()
// Start viewer server (no auth, live wall only) — after main server is created.
if cfg.Server.ViewerPort != "" {
go func() {
if err := app.server.StartViewerServer(); err != nil {
log.Printf("Viewer server: %v", err)
}
}()
}
select { select {
case sig := <-sigCh: case sig := <-sigCh:
log.Printf("Received signal: %v — shutting down", sig) log.Printf("Received signal: %v — shutting down", sig)
@ -137,6 +147,14 @@ func (a *App) StartServer() error {
return srv.ListenAndServe() return srv.ListenAndServe()
} }
// StartViewerServer launches the viewer-only server.
func (a *App) StartViewerServer() error {
if a.server == nil {
return fmt.Errorf("main server not initialized")
}
return a.server.StartViewerServer()
}
// Shutdown performs a graceful shutdown of all services. // Shutdown performs a graceful shutdown of all services.
func (a *App) Shutdown() { func (a *App) Shutdown() {
if a.recorder != nil { if a.recorder != nil {

View file

@ -16,12 +16,26 @@ document.addEventListener('DOMContentLoaded', async () => {
renderPlaybackCameras(); renderPlaybackCameras();
renderCameraCards(); renderCameraCards();
// Read role from meta tag (set by server).
const role = document.querySelector('meta[name="nextnvr-role"]')?.content || '';
applyRole(role);
// First-run experience: auto-switch to Settings if no cameras configured. // First-run experience: auto-switch to Settings if no cameras configured.
if (cameras.length === 0) { if (cameras.length === 0) {
switchTab('settings'); switchTab('settings');
} }
}); });
function applyRole(role) {
if (role === 'viewer') {
// Hide playback and settings tabs.
document.querySelectorAll('.tab[data-tab="playback"], .tab[data-tab="settings"]').forEach(t => t.style.display = 'none');
// Hide save button if present.
const saveBtn = document.getElementById('btn-save');
if (saveBtn) saveBtn.style.display = 'none';
}
}
// ── Tabs ── // ── Tabs ──
function setupTabs() { function setupTabs() {
document.querySelectorAll('.tab').forEach(btn => { document.querySelectorAll('.tab').forEach(btn => {

View file

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="nextnvr-role" content="">
<title>NextNVR</title> <title>NextNVR</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>

74
public/login.html Normal file
View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NextNVR — Login</title>
<style>
:root { --bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #c9d1d9; --text-muted: #8b949e; --accent: #58a6ff;
--red: #f85149; --radius: 8px; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 32px; width: 100%; max-width: 360px;
}
.login-box h1 { font-size: 20px; text-align: center; margin-bottom: 24px; }
.login-box label { font-size: 12px; color: var(--text-muted); display: block; margin-top: 12px; }
.login-box input {
width: 100%; padding: 10px 12px; margin-top: 4px;
background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: 4px; font-size: 14px;
}
.login-box button {
width: 100%; margin-top: 20px; padding: 12px;
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius); font-size: 14px; cursor: pointer;
}
.login-box button:hover { opacity: 0.9; }
.error { color: var(--red); font-size: 13px; margin-top: 8px; text-align: center; }
</style>
</head>
<body>
<div class="login-box">
<h1>🎥 NextNVR</h1>
<form id="login-form">
<label>Username</label>
<input type="text" id="username" autocomplete="username" required>
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Sign In</button>
</form>
<div class="error" id="error"></div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const user = document.getElementById('username').value;
const pass = document.getElementById('password').value;
const err = document.getElementById('error');
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const j = await r.json();
if (j.success) {
window.location.href = '/';
} else {
err.textContent = j.error || 'Login failed';
}
} catch(e) {
err.textContent = 'Connection error';
}
});
</script>
</body>
</html>

View file

@ -25,8 +25,9 @@ type Server struct {
config Config config Config
http *http.Server http *http.Server
mux *http.ServeMux mux *http.ServeMux
appConfig *Config // mutable config reference for hot-reload appConfig *Config
mu sync.RWMutex mu sync.RWMutex
sessions *SessionStore
} }
// NewServer creates and configures the HTTP server. // NewServer creates and configures the HTTP server.
@ -35,13 +36,17 @@ func NewServer(cfg Config) (*Server, error) {
config: cfg, config: cfg,
appConfig: &cfg, appConfig: &cfg,
mux: http.NewServeMux(), mux: http.NewServeMux(),
sessions: NewSessionStore(),
} }
s.registerRoutes() s.registerRoutes()
// Wrap with auth middleware for port 8080.
authHandler := s.authRequired(s.mux)
s.http = &http.Server{ s.http = &http.Server{
Addr: cfg.Server.BindHost + cfg.Server.Port, Addr: cfg.Server.BindHost + cfg.Server.Port,
Handler: s.middleware(s.mux), Handler: authHandler,
} }
return s, nil return s, nil
@ -53,6 +58,19 @@ func (s *Server) ListenAndServe() error {
return s.http.ListenAndServe() 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. // Close shuts down the HTTP server.
func (s *Server) Close() { func (s *Server) Close() {
if s.http != nil { if s.http != nil {
@ -67,6 +85,31 @@ func (s *Server) go2rtcProxy() http.Handler {
return httputil.NewSingleHostReverseProxy(target) 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. // registerRoutes sets up all API and static file routes.
func (s *Server) registerRoutes() { func (s *Server) registerRoutes() {
// API endpoints. // API endpoints.
@ -77,7 +120,17 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/api/status", s.handleStatus) s.mux.HandleFunc("/api/status", s.handleStatus)
s.mux.HandleFunc("/api/recordings", s.handleRecordings) s.mux.HandleFunc("/api/recordings", s.handleRecordings)
// Live MJPEG stream proxy — proxied through NextNVR to avoid cross-origin issues. // 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) s.mux.HandleFunc("/stream/", s.handleStream)
// go2rtc reverse proxy — serves the go2rtc web UI and API through NextNVR. // go2rtc reverse proxy — serves the go2rtc web UI and API through NextNVR.
@ -89,9 +142,9 @@ func (s *Server) registerRoutes() {
s.mux.Handle("/recordings/", http.StripPrefix("/recordings/", s.mux.Handle("/recordings/", http.StripPrefix("/recordings/",
http.FileServer(http.Dir(s.config.Storage.RecordingsPath)))) http.FileServer(http.Dir(s.config.Storage.RecordingsPath))))
// Static file server for embedded SPA. // Static file server for embedded SPA (with role injection).
publicFS, _ := fs.Sub(publicFiles, "public") publicFS, _ := fs.Sub(publicFiles, "public")
s.mux.Handle("/", http.FileServer(http.FS(publicFS))) s.mux.Handle("/", s.injectRole(http.FileServer(http.FS(publicFS))))
} }
// middleware wraps handlers with CORS, no-cache, and logging. // middleware wraps handlers with CORS, no-cache, and logging.