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 {
|
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",
|
||||||
|
|
|
||||||
11
config.yaml
11
config.yaml
|
|
@ -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
7
go.mod
|
|
@ -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
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 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
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()
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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
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
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue