// NextNVR v0.1.0 — API handlers // REST endpoints for cameras, configuration, ONVIF discovery, and status. package main import ( "encoding/json" "net/http" "strings" ) // 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.yaml 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: 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 } } } *s.appConfig = newCfg if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.yaml"); err != nil { // Try saving to the app's config path. _ = SaveConfig(*s.appConfig, "config.yaml") } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"}) default: jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"}) } } // handleScan triggers ONVIF network discovery. // POST /api/scan 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 } // TODO: M3 — real ONVIF WS-Discovery. // For now, return a stub response with the expected IP range. 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, Manufacturer: "Unknown", Model: "ONVIF Camera", RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101", RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102", Found: false, // requires real ONVIF probe in M3 }) } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results}) } // 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}&date={YYYY-MM-DD} 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") date := r.URL.Query().Get("date") // TODO: M2/M3 — scan filesystem for actual .mp4 files. type Clip struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` Time string `json:"time"` } _ = camID _ = date clips := make([]Clip, 0) 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 }