471 lines
13 KiB
Go
471 lines
13 KiB
Go
// NextNVR — MIT License
|
|
// Copyright (c) 2026 NextNVR Contributors
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// REST endpoints for cameras, configuration, ONVIF discovery, and status.
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// handleCameras returns the list of all cameras with runtime status.
|
|
// GET /api/cameras
|
|
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
return
|
|
}
|
|
|
|
statuses := make([]CameraStatus, 0, len(s.appConfig.Cameras))
|
|
for _, cam := range s.appConfig.Cameras {
|
|
statuses = append(statuses, CameraStatus{
|
|
CameraConfig: cam,
|
|
Online: false, // TODO: real stream health check in M2
|
|
Uptime: "n/a",
|
|
Streams: 0,
|
|
})
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: statuses})
|
|
}
|
|
|
|
// handleCameraByID handles single-camera operations.
|
|
// GET /api/cameras/{id} — get camera details
|
|
// PUT /api/cameras/{id} — update camera config
|
|
func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
|
|
camID := strings.TrimPrefix(r.URL.Path, "/api/cameras/")
|
|
if camID == "" {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "camera ID required"})
|
|
return
|
|
}
|
|
|
|
cam, idx := s.findCamera(camID)
|
|
if cam == nil {
|
|
jsonResponse(w, http.StatusNotFound, APIResponse{Error: "camera not found: " + camID})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
status := CameraStatus{
|
|
CameraConfig: *cam,
|
|
Online: false,
|
|
Uptime: "n/a",
|
|
Streams: 0,
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
|
|
|
case http.MethodPut:
|
|
var updated CameraConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
updated.ID = camID
|
|
s.appConfig.Cameras[idx] = updated
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: updated})
|
|
|
|
default:
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
}
|
|
}
|
|
|
|
// handleConfig handles configuration read/write.
|
|
// GET /api/config — return current config (without passwords)
|
|
// POST /api/config — save full config to config.json
|
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Return config with passwords masked.
|
|
safe := *s.appConfig
|
|
for i := range safe.Cameras {
|
|
if safe.Cameras[i].Password != "" {
|
|
safe.Cameras[i].Password = "********"
|
|
}
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: safe})
|
|
|
|
case http.MethodPost:
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var newCfg Config
|
|
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Preserve passwords if not provided (masked in UI).
|
|
for i := range newCfg.Cameras {
|
|
if newCfg.Cameras[i].Password == "********" || newCfg.Cameras[i].Password == "" {
|
|
if old := s.findCameraByIP(newCfg.Cameras[i].IP); old != nil {
|
|
newCfg.Cameras[i].Password = old.Password
|
|
}
|
|
}
|
|
// Sanitize name: replace spaces with underscores for CLI-friendly paths.
|
|
newCfg.Cameras[i].Name = strings.ReplaceAll(newCfg.Cameras[i].Name, " ", "_")
|
|
}
|
|
|
|
// Hash new master password if provided (not masked, not empty, not already hashed).
|
|
if newCfg.Auth.Master.Password != "" && newCfg.Auth.Master.Password != "********" &&
|
|
!strings.HasPrefix(newCfg.Auth.Master.Password, "$2a$") {
|
|
if hash, err := HashPassword(newCfg.Auth.Master.Password); err == nil {
|
|
newCfg.Auth.Master.Password = hash
|
|
}
|
|
}
|
|
// Preserve master password if masked in UI.
|
|
if newCfg.Auth.Master.Password == "********" && s.appConfig.Auth.Master.Password != "" {
|
|
newCfg.Auth.Master.Password = s.appConfig.Auth.Master.Password
|
|
}
|
|
|
|
*s.appConfig = newCfg
|
|
|
|
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.json"); err != nil {
|
|
// Try saving to the app's config path.
|
|
_ = SaveConfig(*s.appConfig, "config.json")
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})
|
|
|
|
default:
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
}
|
|
}
|
|
|
|
// handleConfigReload restarts internal services with the current config.
|
|
// POST /api/config/reload
|
|
func (s *Server) handleConfigReload(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
return
|
|
}
|
|
if s.onReload != nil {
|
|
s.onReload()
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "reloaded"})
|
|
}
|
|
|
|
// handleStream proxies MJPEG streams from go2rtc to avoid cross-origin issues.
|
|
// GET /stream/{cam_id}?type=sub (default: sub stream via go2rtc)
|
|
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
|
camID := strings.TrimPrefix(r.URL.Path, "/stream/")
|
|
if camID == "" {
|
|
http.Error(w, "camera ID required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
streamType := r.URL.Query().Get("type")
|
|
if streamType == "" {
|
|
streamType = "sub"
|
|
}
|
|
|
|
go2rtcURL := "http://127.0.0.1:1984/api/stream.mjpeg?src=" + camID + "_" + streamType
|
|
|
|
resp, err := httpClient.Get(go2rtcURL)
|
|
if err != nil {
|
|
http.Error(w, "stream unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy headers from go2rtc.
|
|
for k, v := range resp.Header {
|
|
for _, vv := range v {
|
|
w.Header().Add(k, vv)
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
// Stream the MJPEG data directly to the client.
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
// POST /api/scan
|
|
// Body: {"method":"range","from":"192.168.1.200","to":"192.168.1.210","username":"admin","password":"..."}
|
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
return
|
|
}
|
|
|
|
var req ScanRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
// Fall back to legacy stub scan if no body provided.
|
|
s.legacyScan(w)
|
|
return
|
|
}
|
|
|
|
if req.From == "" || req.To == "" {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "from and to IP addresses required"})
|
|
return
|
|
}
|
|
|
|
fromIP := net.ParseIP(req.From)
|
|
toIP := net.ParseIP(req.To)
|
|
if fromIP == nil || toIP == nil {
|
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid IP address"})
|
|
return
|
|
}
|
|
|
|
results := make([]ONVIFDiscovery, 0)
|
|
// Inclusive scan from fromIP to toIP.
|
|
ip := make(net.IP, len(fromIP))
|
|
copy(ip, fromIP)
|
|
for {
|
|
d := probeDevice(ip.String(), req.Username, req.Password)
|
|
results = append(results, d)
|
|
if ip.Equal(toIP) {
|
|
break
|
|
}
|
|
ip = nextIP(ip)
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
|
}
|
|
|
|
// legacyScan is the old stub handler for backward compatibility.
|
|
func (s *Server) legacyScan(w http.ResponseWriter) {
|
|
type DiscoveredCamera struct {
|
|
IP string `json:"ip"`
|
|
Manufacturer string `json:"manufacturer"`
|
|
Model string `json:"model"`
|
|
RTSPMain string `json:"rtsp_main"`
|
|
RTSPSub string `json:"rtsp_sub"`
|
|
Found bool `json:"found"`
|
|
}
|
|
results := make([]DiscoveredCamera, 0)
|
|
for ip := 201; ip <= 209; ip++ {
|
|
if ip == 204 {
|
|
continue
|
|
}
|
|
ipStr := "192.168.1." + itoa(ip)
|
|
results = append(results, DiscoveredCamera{
|
|
IP: ipStr,
|
|
Found: false,
|
|
RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101",
|
|
RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102",
|
|
})
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
|
}
|
|
|
|
// nextIP returns the next IP address in sequence.
|
|
func nextIP(ip net.IP) net.IP {
|
|
next := make(net.IP, len(ip))
|
|
copy(next, ip)
|
|
for i := len(next) - 1; i >= 0; i-- {
|
|
next[i]++
|
|
if next[i] != 0 {
|
|
break
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
// handleStatus returns server health and runtime information.
|
|
// GET /api/status
|
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
return
|
|
}
|
|
|
|
status := map[string]interface{}{
|
|
"version": Version,
|
|
"cameras_total": len(s.appConfig.Cameras),
|
|
"cameras_active": 0, // TODO: real counts in M2
|
|
"uptime": "n/a",
|
|
}
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
|
}
|
|
|
|
// handleRecordings returns available recordings for playback.
|
|
// GET /api/recordings?cam={id}&preset={today|yesterday|week}&from={date}&to={date}
|
|
//
|
|
// The recordings directory structure is flat per camera:
|
|
//
|
|
// /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4
|
|
//
|
|
// In-progress recordings use .part.mp4 suffix and are excluded from results.
|
|
func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
|
return
|
|
}
|
|
|
|
camID := r.URL.Query().Get("cam")
|
|
preset := r.URL.Query().Get("preset")
|
|
fromStr := r.URL.Query().Get("from")
|
|
toStr := r.URL.Query().Get("to")
|
|
|
|
type Clip struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Time string `json:"time"`
|
|
Live bool `json:"live"`
|
|
Snap string `json:"snap"`
|
|
}
|
|
|
|
clips := make([]Clip, 0)
|
|
|
|
if camID == "" {
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
|
return
|
|
}
|
|
|
|
// Resolve camera name for directory lookup.
|
|
cam, _ := s.findCamera(camID)
|
|
camDir := camID
|
|
if cam != nil && cam.Name != "" {
|
|
camDir = cam.Name
|
|
}
|
|
|
|
// Determine date range from preset or custom range.
|
|
var fromTime, toTime time.Time
|
|
now := time.Now()
|
|
todayEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
|
|
|
|
switch preset {
|
|
case "today":
|
|
fromTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
toTime = todayEnd
|
|
case "yesterday":
|
|
yesterday := now.AddDate(0, 0, -1)
|
|
fromTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, now.Location())
|
|
toTime = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 23, 59, 59, 0, now.Location())
|
|
case "week":
|
|
fromTime = now.AddDate(0, 0, -7)
|
|
toTime = todayEnd
|
|
default:
|
|
if fromStr != "" {
|
|
fromTime, _ = time.ParseInLocation("2006-01-02", fromStr, now.Location())
|
|
}
|
|
if toStr != "" {
|
|
toTime, _ = time.ParseInLocation("2006-01-02", toStr, now.Location())
|
|
toTime = toTime.Add(24*time.Hour - time.Second)
|
|
}
|
|
if fromStr == "" {
|
|
fromTime = now.AddDate(0, 0, -1)
|
|
}
|
|
if toStr == "" {
|
|
toTime = todayEnd
|
|
}
|
|
}
|
|
|
|
// Scan the flat camera directory.
|
|
recDir := s.appConfig.Storage.RecordingsPath
|
|
scanDir := recDir + "/" + camDir
|
|
|
|
entries, err := os.ReadDir(scanDir)
|
|
if err != nil {
|
|
// Directory doesn't exist yet — no recordings.
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
|
|
// Only process rec_*.mp4 files (not snap_*.jpg).
|
|
if !strings.HasPrefix(name, "rec_") || !strings.HasSuffix(strings.ToLower(name), ".mp4") {
|
|
continue
|
|
}
|
|
|
|
info, statErr := entry.Info()
|
|
if statErr != nil {
|
|
continue
|
|
}
|
|
|
|
clip := Clip{
|
|
Name: name,
|
|
Path: camDir + "/" + name,
|
|
Size: info.Size(),
|
|
}
|
|
|
|
// Matching snapshot: replace rec_ with snap_, .mp4 with .jpg
|
|
snapName := strings.Replace(name, "rec_", "snap_", 1)
|
|
snapName = strings.Replace(snapName, ".mp4", ".jpg", 1)
|
|
snapPath := camDir + "/" + snapName
|
|
if _, err := os.Stat(scanDir + "/" + snapName); err == nil {
|
|
clip.Snap = snapPath
|
|
}
|
|
|
|
// Extract time from filename: rec_YYYY-MM-DD-HH-MM-SS.mp4
|
|
timeStr := strings.TrimPrefix(name, "rec_")
|
|
timeStr = strings.TrimSuffix(timeStr, ".mp4")
|
|
timeStr = strings.TrimSuffix(timeStr, ".part")
|
|
if t, err := time.ParseInLocation("2006-01-02-15-04-05", timeStr, now.Location()); err == nil {
|
|
clip.Time = t.Format("15:04")
|
|
if !fromTime.IsZero() && t.Before(fromTime) {
|
|
continue
|
|
}
|
|
if !toTime.IsZero() && t.After(toTime) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
clips = append(clips, clip)
|
|
}
|
|
|
|
// Reverse — newest first.
|
|
for i, j := 0, len(clips)-1; i < j; i, j = i+1, j-1 {
|
|
clips[i], clips[j] = clips[j], clips[i]
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
|
}
|
|
|
|
// findCamera locates a camera by ID.
|
|
func (s *Server) findCamera(id string) (*CameraConfig, int) {
|
|
for i, cam := range s.appConfig.Cameras {
|
|
if cam.ID == id {
|
|
return &s.appConfig.Cameras[i], i
|
|
}
|
|
}
|
|
return nil, -1
|
|
}
|
|
|
|
// findCameraByIP locates a camera by IP address.
|
|
func (s *Server) findCameraByIP(ip string) *CameraConfig {
|
|
for i := range s.appConfig.Cameras {
|
|
if s.appConfig.Cameras[i].IP == ip {
|
|
return &s.appConfig.Cameras[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// itoa is a simple int-to-string helper (avoids importing strconv).
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
digits := ""
|
|
for n := i; n > 0; n /= 10 {
|
|
digits = string(rune('0'+n%10)) + digits
|
|
}
|
|
return digits
|
|
}
|
|
|
|
// formatBytes returns a human-readable byte count.
|
|
func formatBytes(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return itoa(int(bytes)) + " B"
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B"
|
|
}
|