From 1f253283f8c4275e2ff78bbcc02fb216da07368e Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 08:50:21 -0400 Subject: [PATCH 1/4] fix: harden camera API endpoints (CUB-234) - Add request validation: Content-Type check, body size limit (64KB) - Add field length validation (camera_id: 64, friendly_name: 128, mode: 32, resolution: 32) - Add FPS range validation (0-240) - Add battery_pct range validation (0-100) - Replace ad-hoc map[string]string errors with structured APIError {error, code, details} - Fix isUniqueConstraintErr to catch both camera_id and mac_address constraint violations - Fix MacAddress model field from string to *string for NULL handling - Fix splitSQL to strip -- line comments before splitting (was causing migration failures with modernc.org/sqlite) - Add 30 integration tests covering all endpoints - All tests pass: ok github.com/cubecraft/remoterig/internal/api --- go.mod | 2 +- go.sum | 14 + internal/api/camera_test.go | 596 ++++++++++++++++++++++++++++++++++++ internal/api/cameras.go | 39 ++- internal/api/harden.go | 116 +++++++ internal/api/recording.go | 36 ++- internal/api/status.go | 45 ++- internal/db/db.go | 53 ++-- pkg/models/camera.go | 2 +- 9 files changed, 835 insertions(+), 68 deletions(-) create mode 100644 internal/api/camera_test.go create mode 100644 internal/api/harden.go diff --git a/go.mod b/go.mod index e30b089..2a437f8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cubecraft/remoterig -go 1.19 +go 1.25.0 require ( github.com/eclipse/paho.mqtt.golang v1.5.0 diff --git a/go.sum b/go.sum index 320629e..ee14d76 100644 --- a/go.sum +++ b/go.sum @@ -5,11 +5,13 @@ github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTq github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -17,6 +19,7 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -25,16 +28,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -42,8 +52,12 @@ modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/camera_test.go b/internal/api/camera_test.go new file mode 100644 index 0000000..5d087ac --- /dev/null +++ b/internal/api/camera_test.go @@ -0,0 +1,596 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cubecraft/remoterig/internal/db" + "github.com/go-chi/chi/v5" +) + +// setupTestRouter creates a test router backed by a temp file database so +// pooled connections all see the same data. +func setupTestRouter(t *testing.T) (*db.DB, chi.Router) { + t.Helper() + database, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("failed to open test db: %v", err) + } + + r := chi.NewRouter() + r.Get("/cameras", ListCameras(database)) + r.Post("/cameras", RegisterCamera(database)) + r.Get("/cameras/{id}", GetCameraDetail(database)) + r.Post("/cameras/{id}/start", StartRecording(database)) + r.Post("/cameras/{id}/stop", StopRecording(database)) + r.Post("/cameras/{id}/status", PushStatus(database)) + + return database, r +} + +func newReq(method, target string, body io.Reader) *http.Request { + return httptest.NewRequest(method, target, body) +} + +func assertStatus(t *testing.T, resp *http.Response, expected int) { + t.Helper() + if resp.StatusCode != expected { + t.Errorf("expected status %d, got %d", expected, resp.StatusCode) + } +} + +func assertError(t *testing.T, resp *http.Response, expectedStatus int, want string) { + t.Helper() + assertStatus(t, resp, expectedStatus) + body, _ := io.ReadAll(resp.Body) + var e APIError + if err := json.Unmarshal(body, &e); err != nil { + t.Fatalf("failed to unmarshal error: %v (body: %s)", err, string(body)) + } + if e.Code != expectedStatus { + t.Errorf("expected code %d, got %d", expectedStatus, e.Code) + } + if !strings.Contains(e.Error, want) { + t.Errorf("expected error containing %q, got %q", want, e.Error) + } +} + +func regCamera(t *testing.T, db *db.DB) string { + t.Helper() + w := httptest.NewRecorder() + r := newReq("POST", "/cameras", strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test Camera"}`)) + r.Header.Set("Content-Type", "application/json") + RegisterCamera(db)(w, r) + return "CAM-001" +} + +// ==================== GET /cameras ==================== + +func TestListCameras_Empty(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("GET", "/cameras", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) + + var cameras []map[string]interface{} + json.NewDecoder(w.Result().Body).Decode(&cameras) + if cameras == nil { + t.Error("expected non-nil cameras array, got nil") + } +} + +func TestListCameras_WithData(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + // Push a status + sr := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":85,"recording":false,"mode":"video","resolution":"4K","fps":30,"online":true}`)) + sr.Header.Set("Content-Type", "application/json") + sw := httptest.NewRecorder() + r.ServeHTTP(sw, sr) + assertStatus(t, sw.Result(), http.StatusOK) + + // Now list + lr := newReq("GET", "/cameras", nil) + lw := httptest.NewRecorder() + r.ServeHTTP(lw, lr) + assertStatus(t, lw.Result(), http.StatusOK) + + var cameras []map[string]interface{} + json.NewDecoder(lw.Result().Body).Decode(&cameras) + if len(cameras) != 1 { + t.Errorf("expected 1 camera, got %d", len(cameras)) + } +} + +// ==================== POST /cameras (Register) ==================== + +func TestRegisterCamera_Success(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusCreated) +} + +func TestRegisterCamera_WithMacAddress(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test","mac_address":"00:11:22:33:44:55"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusCreated) +} + +func TestRegisterCamera_MissingBody(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") +} + +func TestRegisterCamera_InvalidJSON(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", strings.NewReader(`{not json`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") +} + +func TestRegisterCamera_MissingRequiredFields(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", + strings.NewReader(`{"friendly_name":"Test"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required") + + req2 := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001"}`)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name is required") +} + +func TestRegisterCamera_FieldTooLong(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + longID := strings.Repeat("x", 65) + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"`+longID+`","friendly_name":"Test"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64") + + longName := strings.Repeat("y", 129) + req2 := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"`+longName+`"}`)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + assertError(t, w2.Result(), http.StatusBadRequest, "friendly_name must be at most 128") +} + +func TestRegisterCamera_WrongContentType(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) + req.Header.Set("Content-Type", "text/plain") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusUnsupportedMediaType) +} + +func TestRegisterCamera_NoContentType(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusCreated) +} + +func TestRegisterCamera_BodyTooLarge(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + req := httptest.NewRequest("POST", "/cameras", bytes.NewReader(make([]byte, 70000))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "too large") +} + +func TestRegisterCamera_Duplicate(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras", + strings.NewReader(`{"camera_id":"CAM-001","friendly_name":"Test"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusConflict, "camera already registered") +} + +// ==================== GET /cameras/{id} ==================== + +func TestGetCameraDetail_Success(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("GET", "/cameras/CAM-001", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) +} + +func TestGetCameraDetail_NotFound(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("GET", "/cameras/NONEXISTENT", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusNotFound, "camera not found") +} + +func TestGetCameraDetail_BadID(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("GET", "/cameras/"+strings.Repeat("x", 65), nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "camera_id must be at most 64") +} + +// ==================== POST /cameras/{id}/start ==================== + +func TestStartRecording_Success(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/start", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) + + var resp map[string]string + json.NewDecoder(w.Result().Body).Decode(&resp) + if resp["status"] != "recording_started" { + t.Errorf("expected recording_started, got %q", resp["status"]) + } +} + +func TestStartRecording_CameraNotFound(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras/NONEXISTENT/start", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusNotFound, "camera not found") +} + +func TestStartRecording_MissingID(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras//start", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "camera_id is required") +} + +// ==================== POST /cameras/{id}/stop ==================== + +func TestStopRecording_Success(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + // Start first + sr := newReq("POST", "/cameras/CAM-001/start", nil) + sw := httptest.NewRecorder() + r.ServeHTTP(sw, sr) + assertStatus(t, sw.Result(), http.StatusOK) + + // Now stop + req := newReq("POST", "/cameras/CAM-001/stop", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) + + var resp map[string]string + json.NewDecoder(w.Result().Body).Decode(&resp) + if resp["status"] != "recording_stopped" { + t.Errorf("expected recording_stopped, got %q", resp["status"]) + } +} + +func TestStopRecording_CameraNotFound(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras/NONEXISTENT/stop", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusNotFound, "camera not found") +} + +// ==================== POST /cameras/{id}/status ==================== + +func TestPushStatus_Success(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) + + var resp map[string]string + json.NewDecoder(w.Result().Body).Decode(&resp) + if resp["status"] != "accepted" { + t.Errorf("expected accepted, got %q", resp["status"]) + } +} + +func TestPushStatus_CameraNotFound(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + req := newReq("POST", "/cameras/NONEXISTENT/status", + strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":60,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusNotFound, "camera not found") +} + +func TestPushStatus_InvalidJSON(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", strings.NewReader(`{bad json`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") +} + +func TestPushStatus_InvalidFPS(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":999,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "fps must be between") +} + +func TestPushStatus_NegativeFPS(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"video","resolution":"1080p","fps":-1,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "fps must be between") +} + +func TestPushStatus_InvalidBattery(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":150,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between") +} + +func TestPushStatus_NegativeBattery(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":-5,"recording":false,"mode":"video","resolution":"1080p","fps":30,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "battery_pct must be between") +} + +func TestPushStatus_ModeTooLong(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", + strings.NewReader(`{"battery_pct":75,"recording":false,"mode":"`+strings.Repeat("x", 33)+`","resolution":"1080p","fps":30,"online":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "mode must be at most") +} + +func TestPushStatus_MissingBody(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + req := newReq("POST", "/cameras/CAM-001/status", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertError(t, w.Result(), http.StatusBadRequest, "invalid request body") +} + +// ==================== Error Response Format ==================== + +func TestErrorResponseFormat_Consistent(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + + checks := []struct { + method, target, body string + }{ + {"GET", "/cameras/NONEXISTENT", ""}, + {"POST", "/cameras", "bad json"}, + {"POST", "/cameras/NONEXISTENT/start", ""}, + {"POST", "/cameras/NONEXISTENT/status", "bad json"}, + } + + for _, c := range checks { + var rd io.Reader + if c.body != "" { + rd = strings.NewReader(c.body) + } + req := newReq(c.method, c.target, rd) + if c.body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var errResp map[string]interface{} + json.NewDecoder(w.Result().Body).Decode(&errResp) + if _, ok := errResp["error"]; !ok { + t.Errorf("%s %s: missing 'error' key: %v", c.method, c.target, errResp) + } + if _, ok := errResp["code"]; !ok { + t.Errorf("%s %s: missing 'code' key: %v", c.method, c.target, errResp) + } + } +} + +// ==================== SQL Injection ==================== + +func TestSQLInjection_CameraID(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + // Chi URL params are extracted after routing, so injection attempts will + // be treated as camera_ids and fail validation (too long) or return 404. + // Use URL encoding for special characters to avoid httptest panics. + paths := []string{ + "/cameras/CAM-001%27+DROP+TABLE+cameras--", + "/cameras/1+UNION+SELECT+NULL--", + "/cameras/%27+OR+%27%27%3D%27", + } + + for _, path := range paths { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + code := w.Result().StatusCode + if code != http.StatusNotFound && code != http.StatusBadRequest { + t.Errorf("unexpected status %d for injection path %s", code, path) + } + } + + // Verify tables still exist + req := httptest.NewRequest("GET", "/cameras", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assertStatus(t, w.Result(), http.StatusOK) +} + +// ==================== Recording Lifecycle ==================== + +func TestRecordingLifecycle(t *testing.T) { + db, r := setupTestRouter(t) + defer db.Close() + regCamera(t, db) + + // Start + r1 := newReq("POST", "/cameras/CAM-001/start", nil) + w1 := httptest.NewRecorder() + r.ServeHTTP(w1, r1) + assertStatus(t, w1.Result(), http.StatusOK) + + // Stop + r2 := newReq("POST", "/cameras/CAM-001/stop", nil) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, r2) + assertStatus(t, w2.Result(), http.StatusOK) + + // Start again + r3 := newReq("POST", "/cameras/CAM-001/start", nil) + w3 := httptest.NewRecorder() + r.ServeHTTP(w3, r3) + assertStatus(t, w3.Result(), http.StatusOK) +} + +// ==================== Benchmark ==================== + +func BenchmarkListCameras(b *testing.B) { + db2, _ := db.Open(b.TempDir() + "/bench.db") + defer db2.Close() + for i := 0; i < 10; i++ { + id := string(rune('A'+i)) + "-CAM" + h := RegisterCamera(db2) + body := `{"camera_id":"` + id + `","friendly_name":"Test ` + string(rune('A'+i)) + `"}` + req := newReq("POST", "/cameras", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h(w, req) + } + jh := ListCameras(db2) + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := newReq("GET", "/cameras", nil) + w := httptest.NewRecorder() + jh(w, req) + } +} diff --git a/internal/api/cameras.go b/internal/api/cameras.go index f8d987b..261720b 100644 --- a/internal/api/cameras.go +++ b/internal/api/cameras.go @@ -6,6 +6,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "github.com/cubecraft/remoterig/internal/db" "github.com/cubecraft/remoterig/pkg/models" @@ -42,7 +43,7 @@ func ListCameras(database *db.DB) http.HandlerFunc { `) if err != nil { log.Printf("Error querying cameras: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } defer rows.Close() @@ -64,7 +65,7 @@ func ListCameras(database *db.DB) http.HandlerFunc { } if err := rows.Err(); err != nil { log.Printf("Error iterating camera rows: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } @@ -84,13 +85,10 @@ func RegisterCamera(database *db.DB) http.HandlerFunc { FriendlyName string `json:"friendly_name"` MacAddress *string `json:"mac_address,omitempty"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + if !decodeJSONBody(w, r, &req) { return } - - if req.CameraID == "" || req.FriendlyName == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id and friendly_name are required"}) + if !validateCameraRegistration(w, req.CameraID, req.FriendlyName) { return } @@ -99,12 +97,12 @@ func RegisterCamera(database *db.DB) http.HandlerFunc { VALUES (?, ?, ?) `, req.CameraID, req.FriendlyName, req.MacAddress) if err != nil { - if err.Error() == "UNIQUE constraint failed: cameras.mac_address" { - respondJSON(w, http.StatusConflict, map[string]string{"error": "camera with this mac_address already registered"}) + if isUniqueConstraintErr(err) { + respondError(w, http.StatusConflict, "camera already registered", err.Error()) return } log.Printf("Error registering camera: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } @@ -124,8 +122,7 @@ func RegisterCamera(database *db.DB) http.HandlerFunc { func GetCameraDetail(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cameraID := chi.URLParam(r, "id") - if cameraID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"}) + if !validateCameraID(w, cameraID) { return } @@ -139,12 +136,12 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc { &c.CreatedAt, &c.UpdatedAt, ) if err == sql.ErrNoRows { - respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not found"}) + respondError(w, http.StatusNotFound, "camera not found", err.Error()) return } if err != nil { log.Printf("Error querying camera: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } @@ -165,7 +162,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc { ) if err != nil && err != sql.ErrNoRows { log.Printf("Error querying latest status: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } @@ -180,7 +177,7 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc { `, cameraID) if err != nil { log.Printf("Error querying history: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } defer historyRows.Close() @@ -203,13 +200,21 @@ func GetCameraDetail(database *db.DB) http.HandlerFunc { } respondJSON(w, http.StatusOK, map[string]interface{}{ - "camera": c, + "camera": c, "last_status": sl, "history": history, }) } } +// isUniqueConstraintErr checks if the error is a SQLite UNIQUE constraint violation. +func isUniqueConstraintErr(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "UNIQUE constraint failed") +} + // respondJSON writes a JSON response with the given status code. func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/harden.go b/internal/api/harden.go new file mode 100644 index 0000000..92d43f6 --- /dev/null +++ b/internal/api/harden.go @@ -0,0 +1,116 @@ +// Package api provides HTTP handlers for camera operations. +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// maxRequestBody is the maximum accepted JSON body size (64KB). +const maxRequestBody = 64 * 1024 + +// APIError represents a structured API error response. +type APIError struct { + Error string `json:"error"` + Code int `json:"code"` + Details string `json:"details,omitempty"` +} + +// validationConstraints defines field-level validation limits. +const ( + maxCameraIDLen = 64 + maxFriendlyNameLen = 128 + maxModeLen = 32 + maxResolutionLen = 32 + minFPS = 0 + maxFPS = 240 +) + +// respondError writes a structured JSON error response. +func respondError(w http.ResponseWriter, status int, msg string, details ...string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + e := APIError{ + Error: msg, + Code: status, + } + if len(details) > 0 { + e.Details = details[0] + } + json.NewEncoder(w).Encode(e) +} + +// decodeJSONBody reads, limits, and decodes a JSON request body. +// Returns false if validation fails (response already written). +func decodeJSONBody(w http.ResponseWriter, r *http.Request, v interface{}) bool { + // Validate Content-Type + ct := r.Header.Get("Content-Type") + if ct != "" && !strings.HasPrefix(ct, "application/json") { + respondError(w, http.StatusUnsupportedMediaType, "content-type must be application/json") + return false + } + + // Limit body size + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) + + body, err := io.ReadAll(r.Body) + if err != nil { + respondError(w, http.StatusBadRequest, "request body too large or unreadable", err.Error()) + return false + } + + if err := json.Unmarshal(body, v); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body", err.Error()) + return false + } + return true +} + +// validateCameraID checks that cameraID is present and within max length. +func validateCameraID(w http.ResponseWriter, cameraID string) bool { + if cameraID == "" { + respondError(w, http.StatusBadRequest, "camera_id is required") + return false + } + if len(cameraID) > maxCameraIDLen { + respondError(w, http.StatusBadRequest, fmt.Sprintf("camera_id must be at most %d characters", maxCameraIDLen)) + return false + } + return true +} + +// validateCameraRegistration validates fields for POST /cameras. +func validateCameraRegistration(w http.ResponseWriter, cameraID, friendlyName string) bool { + if !validateCameraID(w, cameraID) { + return false + } + if friendlyName == "" { + respondError(w, http.StatusBadRequest, "friendly_name is required") + return false + } + if len(friendlyName) > maxFriendlyNameLen { + respondError(w, http.StatusBadRequest, fmt.Sprintf("friendly_name must be at most %d characters", maxFriendlyNameLen)) + return false + } + return true +} + +// validateStatusFields validates optional fields on the PushStatus payload. +func validateStatusFields(w http.ResponseWriter, mode, resolution string, fps int) bool { + if mode != "" && len(mode) > maxModeLen { + respondError(w, http.StatusBadRequest, fmt.Sprintf("mode must be at most %d characters", maxModeLen)) + return false + } + if resolution != "" && len(resolution) > maxResolutionLen { + respondError(w, http.StatusBadRequest, fmt.Sprintf("resolution must be at most %d characters", maxResolutionLen)) + return false + } + if fps < minFPS || fps > maxFPS { + respondError(w, http.StatusBadRequest, fmt.Sprintf("fps must be between %d and %d", minFPS, maxFPS)) + return false + } + return true +} diff --git a/internal/api/recording.go b/internal/api/recording.go index fb5bfca..e862f81 100644 --- a/internal/api/recording.go +++ b/internal/api/recording.go @@ -13,8 +13,7 @@ import ( func StartRecording(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cameraID := chi.URLParam(r, "id") - if cameraID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"}) + if !validateCameraID(w, cameraID) { return } @@ -22,8 +21,13 @@ func StartRecording(database *db.DB) http.HandlerFunc { var exists int err := database.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) - if err != nil || exists == 0 { - respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) + if err != nil { + log.Printf("Error checking camera existence: %v", err) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) + return + } + if exists == 0 { + respondError(w, http.StatusNotFound, "camera not found") return } @@ -34,12 +38,12 @@ func StartRecording(database *db.DB) http.HandlerFunc { `, cameraID) if err != nil { log.Printf("Error starting recording: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } - rows, _ := result.RowsAffected() - log.Printf("Recording started on %s (%d rows affected)", cameraID, rows) + rowsAffected, _ := result.RowsAffected() + log.Printf("Recording started on %s (%d rows affected)", cameraID, rowsAffected) respondJSON(w, http.StatusOK, map[string]string{ "status": "recording_started", @@ -52,8 +56,7 @@ func StartRecording(database *db.DB) http.HandlerFunc { func StopRecording(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cameraID := chi.URLParam(r, "id") - if cameraID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"}) + if !validateCameraID(w, cameraID) { return } @@ -61,8 +64,13 @@ func StopRecording(database *db.DB) http.HandlerFunc { var exists int err := database.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) - if err != nil || exists == 0 { - respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) + if err != nil { + log.Printf("Error checking camera existence: %v", err) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) + return + } + if exists == 0 { + respondError(w, http.StatusNotFound, "camera not found") return } @@ -73,12 +81,12 @@ func StopRecording(database *db.DB) http.HandlerFunc { `, cameraID) if err != nil { log.Printf("Error stopping recording: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } - rows, _ := result.RowsAffected() - log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rows) + rowsAffected, _ := result.RowsAffected() + log.Printf("Recording stopped on %s (%d rows affected)", cameraID, rowsAffected) respondJSON(w, http.StatusOK, map[string]string{ "status": "recording_stopped", diff --git a/internal/api/status.go b/internal/api/status.go index 5ba0f7d..63f6311 100644 --- a/internal/api/status.go +++ b/internal/api/status.go @@ -2,7 +2,6 @@ package api import ( - "encoding/json" "log" "net/http" @@ -14,24 +13,31 @@ import ( func PushStatus(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cameraID := chi.URLParam(r, "id") - if cameraID == "" { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "camera_id required"}) + if !validateCameraID(w, cameraID) { return } var req struct { - BatteryPct *int `json:"battery_pct"` - VideoRemainingSec *int `json:"video_remaining_sec"` - Recording bool `json:"recording"` - Mode string `json:"mode"` - Resolution string `json:"resolution"` - FPS int `json:"fps"` - Online bool `json:"online"` - RawBatteryPct *float64 `json:"raw_battery_pct"` - Timestamp *string `json:"ts"` + BatteryPct *int `json:"battery_pct"` + VideoRemainingSec *int `json:"video_remaining_sec"` + Recording bool `json:"recording"` + Mode string `json:"mode"` + Resolution string `json:"resolution"` + FPS int `json:"fps"` + Online bool `json:"online"` + RawBatteryPct *float64 `json:"raw_battery_pct"` + Timestamp *string `json:"ts"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + if !decodeJSONBody(w, r, &req) { + return + } + if !validateStatusFields(w, req.Mode, req.Resolution, req.FPS) { + return + } + + // Validate battery percentage range if provided + if req.BatteryPct != nil && (*req.BatteryPct < 0 || *req.BatteryPct > 100) { + respondError(w, http.StatusBadRequest, "battery_pct must be between 0 and 100") return } @@ -39,8 +45,13 @@ func PushStatus(database *db.DB) http.HandlerFunc { var exists int err := database.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM cameras WHERE camera_id = ?", cameraID).Scan(&exists) - if err != nil || exists == 0 { - respondJSON(w, http.StatusNotFound, map[string]string{"error": "camera not registered"}) + if err != nil { + log.Printf("Error checking camera existence: %v", err) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) + return + } + if exists == 0 { + respondError(w, http.StatusNotFound, "camera not found") return } @@ -54,7 +65,7 @@ func PushStatus(database *db.DB) http.HandlerFunc { req.FPS, boolToInt(req.Online), req.RawBatteryPct) if err != nil { log.Printf("Error inserting status log: %v", err) - respondJSON(w, http.StatusInternalServerError, map[string]string{"error": "database error"}) + respondError(w, http.StatusInternalServerError, "database error", err.Error()) return } diff --git a/internal/db/db.go b/internal/db/db.go index 1af91c0..cd186a2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -7,6 +7,7 @@ import ( "log" "os" "path/filepath" + "strings" _ "modernc.org/sqlite" ) @@ -67,12 +68,11 @@ func Open(path string) (*DB, error) { return &DB{db}, nil } -// migrate executes a SQL migration string. +// migrate executes a SQL migration string by splitting on semicolons. func migrate(db *sql.DB, sql string) error { - // Split on semicolons to handle multiple statements statements := splitSQL(sql) for _, stmt := range statements { - stmt = stripWhitespace(stmt) + stmt = strings.TrimSpace(stmt) if stmt == "" { continue } @@ -83,8 +83,13 @@ func migrate(db *sql.DB, sql string) error { return nil } -// splitSQL splits a SQL string on semicolons, respecting quoted strings. +// splitSQL splits a SQL string on semicolons, respecting quoted strings +// and stripping SQL line comments (--). func splitSQL(sql string) []string { + // First, strip all line comments (--) to prevent them from swallowing + // subsequent SQL statements when newlines are collapsed. + sql = stripSQLLineComments(sql) + var stmts []string var current string inQuote := false @@ -106,30 +111,42 @@ func splitSQL(sql string) []string { case ';': stmts = append(stmts, current) current = "" + case '\r', '\n', '\t': + current += " " default: current += string(r) } } - if len(current) > 0 { + if strings.TrimSpace(current) != "" { stmts = append(stmts, current) } return stmts } -// stripWhitespace removes leading/trailing whitespace and normalizes newlines. -func stripWhitespace(s string) string { - result := "" - runningSpace := false - for _, r := range s { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' { - if !runningSpace { - result += " " - runningSpace = true +// stripSQLLineComments removes all -- single-line comments from SQL text. +func stripSQLLineComments(sql string) string { + var result strings.Builder + i := 0 + runes := []rune(sql) + + for i < len(runes) { + r := runes[i] + + // Check for -- comment start + if r == '-' && i+1 < len(runes) && runes[i+1] == '-' { + // Skip to end of line + i += 2 + for i < len(runes) && runes[i] != '\n' && runes[i] != '\r' { + i++ } - } else { - result += string(r) - runningSpace = false + // Replace comment with a newline (preserves statement boundaries) + result.WriteRune('\n') + continue } + + result.WriteRune(r) + i++ } - return result + + return result.String() } diff --git a/pkg/models/camera.go b/pkg/models/camera.go index e87f109..bdcc40e 100644 --- a/pkg/models/camera.go +++ b/pkg/models/camera.go @@ -9,7 +9,7 @@ import ( type Camera struct { CameraID string `json:"camera_id"` FriendlyName string `json:"friendly_name"` - MacAddress string `json:"mac_address,omitempty"` + MacAddress *string `json:"mac_address,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } From 74c8697e57b8875b5265d9a6483554a0395f2599 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 09:01:21 -0400 Subject: [PATCH 2/4] fix: hub-side dedup for ESP32 offline status replay (CUB-239) - Add migration 002: UNIQUE index on status_logs(camera_id, recorded_at) - Upgrade migration system to version-tracked (schema_version table) - Prevents race-condition double-inserts that application-level COUNT(*) check cannot guard against - Complements existing application-level dedup from CUB-230 --- internal/db/db.go | 52 ++++++++++++++----- .../db/migrations/002_dedup_unique_index.sql | 8 +++ 2 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 internal/db/migrations/002_dedup_unique_index.sql diff --git a/internal/db/db.go b/internal/db/db.go index 1af91c0..90329a6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -4,6 +4,7 @@ package db import ( "database/sql" _ "embed" + "fmt" "log" "os" "path/filepath" @@ -14,13 +15,16 @@ import ( //go:embed migrations/001_create_tables.sql var migration001 string +//go:embed migrations/002_dedup_unique_index.sql +var migration002 string + // DB wraps the sql.DB with connection-level settings. type DB struct { *sql.DB } // Open opens the SQLite database at the given path, enables WAL mode, -// and runs all migrations if the tables don't exist yet. +// and runs all migrations using a schema_version table for tracking. func Open(path string) (*DB, error) { // Ensure the directory exists dir := filepath.Dir(path) @@ -45,22 +49,46 @@ func Open(path string) (*DB, error) { return nil, err } - // Check if tables already exist (idempotent migration) - var count int - if err := db.QueryRow(` - SELECT COUNT(*) FROM sqlite_master - WHERE type='table' AND name IN ('cameras', 'status_logs', 'recording_events', 'settings') - `).Scan(&count); err != nil { + // Ensure schema_version table exists for migration tracking + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { db.Close() return nil, err } - if count < 4 { - log.Printf("Running migrations for %s...", path) - if err := migrate(db, migration001); err != nil { - db.Close() - return nil, err + // Read current schema version (0 if table is empty) + var currentVersion int + if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(¤tVersion); err != nil { + db.Close() + return nil, err + } + + // Migration definitions: ordered list of (version, sql) + type migration struct { + version int + sql string + } + migrations := []migration{ + {1, migration001}, + {2, migration002}, + } + + for _, m := range migrations { + if currentVersion >= m.version { + continue } + log.Printf("Running migration %d for %s...", m.version, path) + if err := migrate(db, m.sql); err != nil { + db.Close() + return nil, fmt.Errorf("migration %d: %w", m.version, err) + } + if _, err := db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.version); err != nil { + db.Close() + return nil, fmt.Errorf("record migration %d: %w", m.version, err) + } + log.Printf("Migration %d complete", m.version) + } + + if currentVersion < len(migrations) { log.Println("Migrations complete") } diff --git a/internal/db/migrations/002_dedup_unique_index.sql b/internal/db/migrations/002_dedup_unique_index.sql new file mode 100644 index 0000000..9e6d1ab --- /dev/null +++ b/internal/db/migrations/002_dedup_unique_index.sql @@ -0,0 +1,8 @@ +-- Migration: 002_dedup_unique_index +-- Add a UNIQUE index on (camera_id, recorded_at) to enforce hub-side +-- deduplication for ESP32 offline status replay (CUB-239). +-- This prevents race-condition double-inserts that a pure SELECT COUNT(*) +-- check cannot guard against. + +CREATE UNIQUE INDEX IF NOT EXISTS idx_status_logs_unique_entry + ON status_logs(camera_id, recorded_at); From 95c225e51bc4360af3c20a3e80d4efa82c0f868d Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 10:06:50 -0400 Subject: [PATCH 3/4] CUB-229: Design camera auto-discovery and registration flow --- docs/design/camera-auto-discovery.md | 508 +++++++++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 docs/design/camera-auto-discovery.md diff --git a/docs/design/camera-auto-discovery.md b/docs/design/camera-auto-discovery.md new file mode 100644 index 0000000..05f97d9 --- /dev/null +++ b/docs/design/camera-auto-discovery.md @@ -0,0 +1,508 @@ +# Camera Auto-Discovery and Registration Flow — Design Document + +> **Status:** Draft | **CUB:** 229 | **Date:** 2026-05-23 +> **Depends on:** MQTT_CONTRACT.md v1.0.0 | **Affects:** CUB-189 (POST /cameras), CUB-232 (MQTT subscriber) + +--- + +## 1. Overview + +When a new ESP32 camera node powers on and connects to the travel router, it must self-register with the RemoteRig hub without any manual configuration. This document defines the auto-discovery protocol, message schemas, database extensions, error handling, and retry behavior. + +### Design Goals + +1. **Zero-touch provisioning** — ESP32 node registers itself on first MQTT connect; no dashboard interaction required +2. **Re-registration safe** — same node rejoining after a reboot or network blip is recognized and re-associated, not duplicated +3. **Idempotent** — replaying an announce due to MQTT retain or offline buffering does not create duplicate cameras +4. **Observable** — the dashboard receives real-time SSE events when a camera appears or reconnects +5. **Backward compatible** — existing announce format (`MQTT_CONTRACT.md`) is enhanced, not replaced + +--- + +## 2. ESP32 Announce Message (Registration Request) + +### Topic + +``` +remoterig/cameras/+/announce +``` + +**Direction:** ESP32 → Hub | **QoS:** 2 | **Retain:** true + +Published once on ESP32 first boot (or factory reset). Retained so the hub sees it even if it restarts after the ESP32 came online. + +### JSON Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CameraAnnounce", + "type": "object", + "required": ["mac_address", "firmware_version", "capabilities"], + "properties": { + "mac_address": { + "type": "string", + "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", + "description": "ESP32 Wi-Fi station MAC address — the stable, globally unique hardware identifier" + }, + "firmware_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "Semver of the ESP32 firmware (e.g. 0.2.0)" + }, + "capabilities": { + "type": "array", + "items": { "type": "string", "enum": ["start_stop", "status", "reboot", "heartbeat"] }, + "minItems": 1, + "description": "Supported feature flags. Minimal: [\"status\"]. Full: [\"start_stop\", \"status\", \"reboot\", \"heartbeat\"]" + }, + "friendly_name": { + "type": "string", + "maxLength": 64, + "description": "Default human-readable name (e.g. 'ESP32-AA-BB-CC'). If omitted, hub generates one from the MAC." + }, + "device_type": { + "type": "string", + "enum": ["esp32-gopro", "esp32-generic"], + "default": "esp32-gopro", + "description": "Device class for future multi-type support" + }, + "mqtt_client_id": { + "type": "string", + "maxLength": 64, + "description": "The MQTT client ID the ESP32 connected with (diagnostic)" + }, + "sdk_version": { + "type": "string", + "description": "ESP-IDF or Arduino SDK version (diagnostic)" + } + } +} +``` + +### Example — Minimal + +```json +{ + "mac_address": "AA:BB:CC:DD:EE:FF", + "firmware_version": "0.1.0", + "capabilities": ["status", "heartbeat"] +} +``` + +### Example — Full + +```json +{ + "mac_address": "AA:BB:CC:DD:EE:FF", + "firmware_version": "0.2.0", + "capabilities": ["start_stop", "status", "reboot", "heartbeat"], + "friendly_name": "GoPro Hero3 #1", + "device_type": "esp32-gopro", + "mqtt_client_id": "remoterig-ddeeff", + "sdk_version": "ESP-IDF v5.1.4" +} +``` + +### MAC Address as Identity + +The ESP32's Wi-Fi station MAC is the only stable, globally unique identifier available on a closed network (no cloud, no serial number burned at factory). It is: + +- **Globally unique** — OUI-assigned by Espressif +- **Immutable** — persists across firmware flashes and reboots +- **Available before MQTT connect** — no dependency on hub-assigned ID + +The hub maps `mac_address → camera_id`. The `camera_id` (e.g. `cam-001`) is a short, human-friendly alias assigned at registration time. + +--- + +## 3. Hub Response Protocol + +When the hub processes an announce, it MUST publish a response so the ESP32 knows its registration outcome. The response goes to the **command topic** for the assigned camera. + +### Response Topic + +``` +remoterig/cameras//command +``` + +Direction: Hub → ESP32 | QoS: 2 | Retain: false + +### Response Schema + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "RegistrationResponse", + "type": "object", + "required": ["command", "request_id"], + "properties": { + "command": { + "type": "string", + "enum": ["registered", "registration_error"], + "description": "Outcome of the registration request" + }, + "request_id": { + "type": "string", + "description": "Echo of the announce message's MAC + timestamp hash for correlation" + }, + "camera_id": { + "type": "string", + "pattern": "^cam-\\d{3}$", + "description": "Assigned camera ID (present on success only)" + }, + "error_code": { + "type": "string", + "enum": ["INVALID_MAC", "CAPABILITY_REQUIRED", "DB_WRITE_FAILED", "RATE_LIMITED"], + "description": "Machine-readable error code (present on failure only)" + }, + "error_message": { + "type": "string", + "description": "Human-readable error description (present on failure only)" + }, + "retry_after_sec": { + "type": "integer", + "minimum": 5, + "description": "Suggested retry delay in seconds (present on failure only)" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 — hub clock time of the response" + } + } +} +``` + +### Success Response Example + +```json +{ + "command": "registered", + "request_id": "req-AABBCCDDEEFF-1684771200", + "camera_id": "cam-004", + "timestamp": "2026-05-23T14:30:00Z" +} +``` + +### Error Responses + +| error_code | Meaning | retry_after_sec | ESP32 action | +|---|---|---|---| +| `INVALID_MAC` | MAC address absent or malformed | — (fatal) | Log error, halt registration | +| `CAPABILITY_REQUIRED` | No valid capabilities specified | — (fatal) | Log error, halt registration | +| `DB_WRITE_FAILED` | Hub database is unavailable (disk full, etc.) | 60 | Retry after delay | +| `RATE_LIMITED` | Too many registration attempts in a window | 30 | Retry after delay | + +Example error response: + +```json +{ + "command": "registration_error", + "request_id": "req-AABBCCDDEEFF-1684771200", + "error_code": "DB_WRITE_FAILED", + "error_message": "Database write failed: disk I/O error", + "retry_after_sec": 60, + "timestamp": "2026-05-23T14:30:00Z" +} +``` + +### ESP32 Retry Logic + +``` +ESP32 publishes announce (QoS 2, retain) + │ + ├── Subscribe to remoterig/cameras/+/command (QoS 2) + │ + ├── Wait for command = "registered" or "registration_error" + │ + ├── Timeout after 30s → retry announce (with exponential backoff) + │ ├── 1st attempt: immediate + │ ├── 2nd attempt: wait 5s + │ ├── 3rd attempt: wait 10s + │ ├── 4th attempt: wait 20s + │ └── 5th+ attempt: wait 30s, repeat every 30s + │ + ├── On success (registered): store camera_id in NVS, begin normal status loop + │ + ├── On fatal error (INVALID_MAC, CAPABILITY_REQUIRED): + │ Log error, blink LED pattern, do not retry + │ + └── On transient error (DB_WRITE_FAILED, RATE_LIMITED): + Wait retry_after_sec (capped at 120s), then re-publish announce +``` + +**After successful registration:** On subsequent boots, the ESP32 reads `camera_id` from NVS (non-volatile storage). It does NOT re-publish announce unless: +- `camera_id` is missing from NVS (factory reset / first boot) +- The hub publishes `command: "reregister"` to force re-registration (admin action) + +--- + +## 4. Hub Processing Logic + +### Registration Flow + +``` +Hub receives announce on remoterig/cameras/+/announce + │ + ├── 1. VALIDATE: mac_address present? matches pattern? → if no: publish INVALID_MAC error + │ + ├── 2. VALIDATE: capabilities non-empty? → if no: publish CAPABILITY_REQUIRED error + │ + ├── 3. RATE LIMIT: >5 registrations from same IP/MAC in 60s? → RATE_LIMITED error + │ + ├── 4. LOOKUP: SELECT camera_id FROM cameras WHERE mac_address = ? + │ │ + │ ├── FOUND → EXISTING CAMERA: + │ │ ├── Update: friendly_name, firmware_version, capabilities, updated_at + │ │ ├── Publish registered response with existing camera_id + │ │ ├── SSE broadcast: "camera_reconnected" + │ │ └── Clear MQTT stale announce (publish empty retained message) + │ │ + │ └── NOT FOUND → NEW CAMERA: + │ ├── Generate camera_id: "cam-NNN" (sequential) + │ ├── INSERT into cameras + │ ├── Publish registered response with new camera_id + │ ├── SSE broadcast: "camera_registered" + │ └── Clear MQTT stale announce (publish empty retained message) + │ + └── 5. CLEANUP: Publish zero-byte retained message to announce topic + (prevents stale announces after camera is registered) +``` + +### Rate Limiting + +To protect against buggy firmware or network loops: + +| Window | Max Attempts | Action | +|--------|-------------|--------| +| 60 seconds | 5 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 30` | +| 5 minutes | 20 per MAC | Reject with `RATE_LIMITED`, `retry_after_sec: 60` | + +Rate limit state is in-memory only (not persisted). Restarting the hub resets the counters. + +--- + +## 5. Database Schema Changes + +### Extended `cameras` Table + +```sql +-- Migration: 002_add_camera_registration_fields.sql + +ALTER TABLE cameras ADD COLUMN firmware_version TEXT; +ALTER TABLE cameras ADD COLUMN capabilities TEXT NOT NULL DEFAULT '["status"]'; +ALTER TABLE cameras ADD COLUMN device_type TEXT NOT NULL DEFAULT 'esp32-gopro'; +ALTER TABLE cameras ADD COLUMN registration_status TEXT NOT NULL DEFAULT 'pending' + CHECK(registration_status IN ('pending', 'registered', 'error', 'decommissioned')); +ALTER TABLE cameras ADD COLUMN last_announce_at DATETIME; +ALTER TABLE cameras ADD COLUMN registration_error TEXT; +ALTER TABLE cameras ADD COLUMN mqtt_client_id TEXT; + +-- Index for MAC lookups (already exists but confirm) +-- CREATE INDEX IF NOT EXISTS idx_cameras_mac ON cameras(mac_address); + +-- Index for registration status filtering +CREATE INDEX IF NOT EXISTS idx_cameras_reg_status ON cameras(registration_status); + +-- Index for finding stale registrations (cameras that announced but never sent status) +CREATE INDEX IF NOT EXISTS idx_cameras_last_announce ON cameras(last_announce_at); +``` + +### Full Table Definition (post-migration) + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `camera_id` | TEXT | PK | Hub-assigned short ID, e.g. `cam-001` | +| `friendly_name` | TEXT | NOT NULL | Human-readable name | +| `mac_address` | TEXT | UNIQUE | ESP32 Wi-Fi station MAC | +| `firmware_version` | TEXT | — | Firmware semver reported by ESP32 | +| `capabilities` | TEXT | NOT NULL, DEFAULT `'["status"]'` | JSON array of strings | +| `device_type` | TEXT | NOT NULL, DEFAULT `'esp32-gopro'` | Device class | +| `registration_status` | TEXT | NOT NULL, DEFAULT `'pending'` | `pending`, `registered`, `error`, `decommissioned` | +| `last_announce_at` | DATETIME | — | Timestamp of most recent announce | +| `registration_error` | TEXT | — | Last registration error message (cleared on success) | +| `mqtt_client_id` | TEXT | — | MQTT client ID from the announce | +| `created_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | First registration timestamp | +| `updated_at` | DATETIME | NOT NULL, DEFAULT `datetime('now')` | Last update timestamp | + +### Go Model Extension + +The existing `models.Camera` struct gains: + +```go +type Camera struct { + CameraID string `json:"camera_id"` + FriendlyName string `json:"friendly_name"` + MacAddress string `json:"mac_address,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + Capabilities []string `json:"capabilities"` + DeviceType string `json:"device_type"` + RegistrationStatus string `json:"registration_status"` + LastAnnounceAt *time.Time `json:"last_announce_at,omitempty"` + RegistrationError string `json:"registration_error,omitempty"` + MqttClientID string `json:"mqtt_client_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +> **Note on `capabilities` storage:** SQLite does not have a native JSON array type. Store as TEXT (JSON-encoded array). Serialize/deserialize in the Go model layer. Migration default is `'["status"]'` — the minimum capability for a useful camera. + +--- + +## 6. Registration Flow Sequence Diagram + +```mermaid +sequenceDiagram + participant ESP32 + participant Broker as MQTT Broker (Mosquitto) + participant Hub as Go Hub + participant DB as SQLite + participant SSE as SSE Hub + participant UI as Dashboard UI + + Note over ESP32: Power on / First boot + + ESP32->>ESP32: Read camera_id from NVS + alt camera_id NOT in NVS (first boot or factory reset) + ESP32->>Broker: CONNECT (client_id: remoterig-) + Broker-->>ESP32: CONNACK + + ESP32->>Broker: SUBSCRIBE remoterig/cameras/+/command (QoS 2) + Broker-->>ESP32: SUBACK + + ESP32->>Broker: PUBLISH remoterig/cameras/announce (QoS 2, retain) + Note over ESP32,Broker: {mac_address, firmware_version, capabilities, ...} + Broker->>Hub: Forward announce + + Hub->>Hub: Validate: MAC present? capabilities non-empty? + alt Validation fails + Hub->>Broker: PUBLISH command {command: "registration_error", error_code: "INVALID_MAC"} + Broker->>ESP32: Forward error + Note over ESP32: Log error, halt (fatal) + else Validation passes + Hub->>Hub: Rate limit check + alt Rate limited + Hub->>Broker: PUBLISH command {error_code: "RATE_LIMITED", retry_after_sec: 30} + Broker->>ESP32: Forward error + Note over ESP32: Wait retry_after_sec, retry + else Allowed + Hub->>DB: SELECT camera_id WHERE mac_address = ? + alt MAC already registered + DB-->>Hub: camera_id = "cam-002" + Hub->>DB: UPDATE cameras SET firmware_version, capabilities, friendly_name, ... + Hub->>SSE: Broadcast "camera_reconnected" + else New MAC + DB-->>Hub: no rows + Hub->>DB: SELECT MAX(camera_id) → "cam-003" + Hub->>Hub: Generate "cam-004" + Hub->>DB: INSERT INTO cameras (cam-004, ...) + Hub->>SSE: Broadcast "camera_registered" + end + + Hub->>Broker: PUBLISH command {command: "registered", camera_id: "cam-004"} + Broker->>ESP32: Forward registration response + + Hub->>Broker: PUBLISH announce (zero-byte retain) — clear stale announce + + SSE-->>UI: camera_registered / camera_reconnected event + UI->>UI: Show new camera card in grid + end + end + else camera_id FOUND in NVS (subsequent boot) + Note over ESP32: Skip announce, proceed to status loop + ESP32->>Broker: PUBLISH status (QoS 1, retain) + Broker->>Hub: Forward status + Hub->>SSE: Broadcast camera_status + SSE-->>UI: Update camera card + end +``` + +--- + +## 7. Reconnection vs. Registration + +It is critical to distinguish two scenarios: + +### Scenario A: Reconnection (camera was previously registered) + +``` +ESP32 boots → reads camera_id from NVS → publishes status on remoterig/cameras//status +→ Hub sees status on a known camera_id → updates online flag → SSE broadcast +``` + +**No announce published.** The camera already has its identity. + +### Scenario B: First Registration (or factory reset) + +``` +ESP32 boots → NVS empty → publishes announce → Hub assigns camera_id → +ESP32 stores camera_id in NVS → begins status loop on remoterig/cameras//status +``` + +### Scenario C: Hub Restart (ESP32 already running) + +``` +Hub restarts → subscribes to remoterig/cameras/+/announce → +MQTT broker delivers retained announce messages → +Hub processes each → re-registration safe (MAC already exists → update only) +``` + +This is why announce messages use `retain: true`. If the hub restarts while ESP32s are running, it re-discovers them from retained announces. + +--- + +## 8. Security Considerations + +| Concern | Mitigation | +|---------|-----------| +| Rogue node spoofing a MAC | Closed network (travel router, no internet). MAC filtering at the router level as defense-in-depth (future). | +| Replay attacks | Announce is idempotent — replaying it only updates timestamps, doesn't create duplicates. | +| Denial of registration | Rate limiting (Section 4) prevents flooding. | +| Unauthorized decommission | No `decommission` MQTT command exists. Decommission is admin-only via HTTP API with API key auth. | + +--- + +## 9. Open Questions & Decisions + +| Question | Decision | Rationale | +|----------|----------|-----------| +| **MAC as identity?** | ✅ Yes | Only globally unique, immutable ID available on a closed network. | +| **`camera_id` format?** | `cam-NNN` (zero-padded sequential) | Short, sortable, human-friendly. Collision-free with DB sequence. | +| **Re-registration behavior?** | Update existing, don't create duplicate | Announcing with same MAC = reconnection, not new camera. | +| **Retain on announce?** | ✅ Yes, cleared after processing | Allows hub restart recovery. Cleanup prevents stale data. | +| **Response protocol?** | Publish to `command` topic | Reuses existing command channel. ESP32 subscribes before publishing announce. | +| **Capabilities stored?** | ✅ Yes, in `capabilities` column | Enables future feature gating (e.g., "this camera can't start/stop recording"). | +| **`device_type` added?** | ✅ Yes, default `esp32-gopro` | Allows future camera types (e.g., Raspberry Pi CSI, USB webcam). | +| **Dashboard rename after auto-registration?** | ✅ Yes (via existing POST /cameras or settings API in future) | Already called out in MQTT_CONTRACT.md. No new work in this CUB. | +| **NVS key for camera_id?** | `"cam_id"` | Simple, unambiguous. | + +--- + +## 10. Implementation Plan + +This design document covers the protocol and schema design. Implementation is tracked in the following sub-issues: + +| CUB | Title | Agent | Depends On | +|-----|-------|-------|------------| +| CUB-229 | Design camera auto-discovery and registration flow | Dex | — (this task) | +| CUB-229a | Migration: add registration fields to cameras table | Hex | CUB-229 | +| CUB-229b | Go model update: Camera struct with new fields | Dex | CUB-229a | +| CUB-229c | MQTT subscriber: registration response protocol | Dex | CUB-229b | +| CUB-229d | Rate limiting for announce messages | Dex | CUB-229b | +| CUB-229e | SSE events: camera_registered / camera_reconnected | Dex | CUB-229c | +| CUB-229f | ESP32 firmware: NVS storage + announce on first boot | Pip | CUB-229 | +| CUB-229g | ESP32 firmware: command subscription + registration ACK handling | Pip | CUB-229c | +| CUB-229h | Update MQTT_CONTRACT.md with registration response spec | Dex | CUB-229 | +| CUB-229i | Integration test: camera auto-registration end-to-end | Dex/Pip | CUB-229e, CUB-229g | + +--- + +## 11. References + +- [MQTT_CONTRACT.md](../MQTT_CONTRACT.md) — Network topology, topic hierarchy, existing status/heartbeat/command schemas +- [CONTEXT.md](../CONTEXT.md) — RemoteRig tech stack, directory layout, database schema +- [CUB-230 (Offline Buffer & Replay)](https://linear.app/cubecraft-creations/issue/CUB-230) — Related: offline buffering uses same dedup strategy +- [CUB-232 (MQTT Subscriber)](https://linear.app/cubecraft-creations/issue/CUB-232) — The subscriber that will implement this registration logic +- [CUB-189 (POST /cameras)](https://linear.app/cubecraft-creations/issue/CUB-189) — HTTP registration endpoint (may be replaced/supplemented by auto-discovery) From dd5ffe9fba77617d0319e18999cd9272f72bc0c3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 23 May 2026 10:37:48 -0400 Subject: [PATCH 4/4] =?UTF-8?q?CUB-176:=20central=20hub=20frontend=20?= =?UTF-8?q?=E2=80=94=20camera=20grid,=20start/stop=20controls,=20history?= =?UTF-8?q?=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CameraCard: color-coded status (green/yellow/red), per-camera start/stop, battery bar, recording indicator - HistoryViewer: modal dialog with 24h status log browsing per camera - App: responsive grid (1-4 cols), Start/Stop All global buttons, SSE connection badge, live stats strip - API service: aligned with backend endpoints (list, detail, start, stop) - Types: added StatusLog, CameraDetail, CameraInfo, StartStopResponse - All 23 tests pass, lint clean, TypeScript + Vite build clean --- src/App.tsx | 244 ++++++++++++++++++++++------- src/components/CameraCard.test.tsx | 90 ++++++----- src/components/CameraCard.tsx | 116 ++++++++++---- src/components/HistoryViewer.tsx | 193 +++++++++++++++++++++++ src/components/index.ts | 1 + src/services/api.ts | 25 ++- src/types/index.ts | 36 +++++ 7 files changed, 576 insertions(+), 129 deletions(-) create mode 100644 src/components/HistoryViewer.tsx diff --git a/src/App.tsx b/src/App.tsx index 1ed0a7c..801a544 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,88 +1,220 @@ -import { Camera, Radio } from 'lucide-react' +import { useState, useCallback, useMemo } from 'react' +import { Camera, Play, Square, Wifi, WifiOff, AlertTriangle } from 'lucide-react' import { useSSE } from './hooks/useSSE' import { useCameraStore } from './store/useCameraStore' -import { CameraCard } from './components' +import { api } from './services/api' +import CameraCard from './components/CameraCard' +import HistoryViewer from './components/HistoryViewer' function App() { - // Connect to SSE endpoint — auto-updates the camera store - useSSE() + const [commandBusy, setCommandBusy] = useState(false) + const [commandError, setCommandError] = useState(null) + const [historyCameraId, setHistoryCameraId] = useState(null) + const [historyCameraName, setHistoryCameraName] = useState() - // Subscribe to the camera store for reactivity. - // getCameras / getOnlineCount / getRecordingCount pull from live state. - const { getCameras, getOnlineCount, getRecordingCount } = useCameraStore() - const cameras = getCameras() - const onlineCount = getOnlineCount() - const recordingCount = getRecordingCount() + // SSE connection + live store + const { connectionState } = useSSE() + + // Subscribe to full camera state — dashboard needs every change + const camerasMap = useCameraStore((s) => s.cameras) + const cameras = useMemo(() => Array.from(camerasMap.values()), [camerasMap]) + const onlineCount = useMemo(() => cameras.filter((c) => c.online).length, [cameras]) + const recordingCount = useMemo(() => cameras.filter((c) => c.recording).length, [cameras]) + + const cameraIds = cameras.map((c) => c.camera_id) + + // ── Command helpers ── + + const handleStart = useCallback(async (cameraId: string) => { + setCommandBusy(true) + setCommandError(null) + try { + await api.startRecording(cameraId) + } catch (err) { + setCommandError(err instanceof Error ? err.message : 'Command failed') + } finally { + setCommandBusy(false) + } + }, []) + + const handleStop = useCallback(async (cameraId: string) => { + setCommandBusy(true) + setCommandError(null) + try { + await api.stopRecording(cameraId) + } catch (err) { + setCommandError(err instanceof Error ? err.message : 'Command failed') + } finally { + setCommandBusy(false) + } + }, []) + + const handleStartAll = useCallback(async () => { + setCommandBusy(true) + setCommandError(null) + try { + await Promise.all(cameraIds.map((id) => api.startRecording(id))) + } catch { + // Individual failures are non-fatal — some may succeed + } finally { + setCommandBusy(false) + } + }, [cameraIds]) + + const handleStopAll = useCallback(async () => { + setCommandBusy(true) + setCommandError(null) + try { + await Promise.all(cameraIds.map((id) => api.stopRecording(id))) + } catch { + // Individual failures are non-fatal + } finally { + setCommandBusy(false) + } + }, [cameraIds]) + + const handleViewHistory = useCallback((cameraId: string) => { + const cam = useCameraStore.getState().cameras.get(cameraId) + setHistoryCameraId(cameraId) + setHistoryCameraName(cam?.friendly_name ?? cameraId) + }, []) + + const handleCloseHistory = useCallback(() => { + setHistoryCameraId(null) + }, []) + + // ── Connection badge ── + + const connectionBadge = { + connected: { icon: Wifi, label: 'Live', class: 'bg-rig-success/15 text-rig-success' }, + connecting: { icon: Wifi, label: 'Connecting...', class: 'bg-rig-warning/15 text-rig-warning' }, + disconnected: { icon: WifiOff, label: 'Disconnected', class: 'bg-rig-danger/15 text-rig-danger' }, + error: { icon: AlertTriangle, label: 'Stream Error', class: 'bg-rig-danger/15 text-rig-danger' }, + }[connectionState] ?? { + icon: WifiOff, + label: 'Disconnected', + class: 'bg-rig-danger/15 text-rig-danger', + } + + const BadgeIcon = connectionBadge.icon + + // ── Render ── return ( -
+
{/* Header */} -
-
-
- -

- RemoteRig -

- - Dashboard - - - {/* Stats badges */} -
- {/* Online count */} - - - {onlineCount} online - - - {/* Recording count */} - - - - - - {recordingCount} recording +
+
+
+
+ +

+ RemoteRig +

+ + Dashboard
+ + {/* Connection status */} +
+ {/* SSE badge */} + + + {connectionBadge.label} + + + {/* Global controls */} +
+ + +
+
+
+ + {/* Stats strip */} +
+ + {cameras.length} camera{cameras.length !== 1 ? 's' : ''} + + + {onlineCount} online + + + 0 ? 'text-rig-danger' : 'text-rig-dark-300'}> + {recordingCount} + {' '} + recording +
+ {/* Command error toast */} + {commandError && ( +
+

+ + {commandError} +

+
+ )} + {/* Main Content */} -
+
{cameras.length === 0 ? ( - /* Empty state */
- - - +

- Waiting for cameras… + No Cameras Connected

- Connect cameras to your RemoteRig server and they will appear here - automatically. + Waiting for camera nodes to connect. Ensure ESP32 bridges are powered on and connected to the network.

) : ( - /* Camera grid */ -
- {cameras.map((camera) => ( - +
+ {cameras.map((cam) => ( + ))}
)}
+ {/* History modal */} + + {/* Footer */} -