feat: two-port auth — 8080 with login, 8090 viewer-only (live wall)
This commit is contained in:
parent
d4eada42d8
commit
eaa612278d
11 changed files with 487 additions and 17 deletions
256
auth.go
Normal file
256
auth.go
Normal 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
|
||||
}
|
||||
17
config.go
17
config.go
|
|
@ -17,6 +17,7 @@ import (
|
|||
type Config struct {
|
||||
Server ServerConfig `yaml:"server" json:"server"`
|
||||
Storage StorageConfig `yaml:"storage" json:"storage"`
|
||||
Auth AuthConfig `yaml:"auth" json:"auth"`
|
||||
Cameras []CameraConfig `yaml:"cameras" json:"cameras"`
|
||||
Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"`
|
||||
}
|
||||
|
|
@ -25,6 +26,7 @@ type Config struct {
|
|||
type ServerConfig struct {
|
||||
Port string `yaml:"port" json:"port"`
|
||||
BindHost string `yaml:"bind_host" json:"bind_host"`
|
||||
ViewerPort string `yaml:"viewer_port" json:"viewer_port"`
|
||||
}
|
||||
|
||||
// StorageConfig holds recording and retention settings.
|
||||
|
|
@ -56,6 +58,20 @@ type Go2RTCConfig struct {
|
|||
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.
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
|
@ -77,6 +93,7 @@ func DefaultConfig() Config {
|
|||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
BindHost: "0.0.0.0",
|
||||
ViewerPort: ":8090",
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
RecordingsPath: "/mnt/recordings",
|
||||
|
|
|
|||
11
config.yaml
11
config.yaml
|
|
@ -4,12 +4,23 @@
|
|||
server:
|
||||
port: ":8080"
|
||||
bind_host: "0.0.0.0"
|
||||
viewer_port: ":8090"
|
||||
|
||||
storage:
|
||||
recordings_path: "/mnt/recordings"
|
||||
retention_days: 7
|
||||
cleanup_interval_mins: 60
|
||||
|
||||
auth:
|
||||
enabled: false
|
||||
master:
|
||||
username: ""
|
||||
password: ""
|
||||
viewer:
|
||||
enabled: true
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
go2rtc:
|
||||
enabled: false
|
||||
port: ":1984"
|
||||
|
|
|
|||
7
go.mod
7
go.mod
|
|
@ -1,5 +1,8 @@
|
|||
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
2
go.sum
|
|
@ -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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
21
go2rtc.yaml
Normal file
21
go2rtc.yaml
Normal 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
22
main.go
|
|
@ -75,12 +75,22 @@ func main() {
|
|||
go cln.Start()
|
||||
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)
|
||||
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 {
|
||||
case sig := <-sigCh:
|
||||
log.Printf("Received signal: %v — shutting down", sig)
|
||||
|
|
@ -137,6 +147,14 @@ func (a *App) StartServer() error {
|
|||
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.
|
||||
func (a *App) Shutdown() {
|
||||
if a.recorder != nil {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,26 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
renderPlaybackCameras();
|
||||
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.
|
||||
if (cameras.length === 0) {
|
||||
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 ──
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="nextnvr-role" content="">
|
||||
<title>NextNVR</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
|
|
|
|||
74
public/login.html
Normal file
74
public/login.html
Normal 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>
|
||||
63
server.go
63
server.go
|
|
@ -25,8 +25,9 @@ type Server struct {
|
|||
config Config
|
||||
http *http.Server
|
||||
mux *http.ServeMux
|
||||
appConfig *Config // mutable config reference for hot-reload
|
||||
appConfig *Config
|
||||
mu sync.RWMutex
|
||||
sessions *SessionStore
|
||||
}
|
||||
|
||||
// NewServer creates and configures the HTTP server.
|
||||
|
|
@ -35,13 +36,17 @@ func NewServer(cfg Config) (*Server, error) {
|
|||
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: s.middleware(s.mux),
|
||||
Handler: authHandler,
|
||||
}
|
||||
|
||||
return s, nil
|
||||
|
|
@ -53,6 +58,19 @@ func (s *Server) ListenAndServe() error {
|
|||
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 {
|
||||
|
|
@ -67,6 +85,31 @@ func (s *Server) go2rtcProxy() http.Handler {
|
|||
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.
|
||||
|
|
@ -77,7 +120,17 @@ func (s *Server) registerRoutes() {
|
|||
s.mux.HandleFunc("/api/status", s.handleStatus)
|
||||
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)
|
||||
|
||||
// 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/",
|
||||
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")
|
||||
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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue