generated from CubeCraft-Creations/Tracehound
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
-17
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+22
-14
@@ -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",
|
||||
|
||||
+28
-17
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+35
-18
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user