Merge branch 'dev' into agent/dex/CUB-239-hub-dedup-replay
CI/CD / lint-and-typecheck (pull_request) Successful in 8s
CI/CD / test (pull_request) Successful in 9s
CI/CD / build (pull_request) Failing after 10s
CI/CD / deploy (pull_request) Has been skipped

This commit is contained in:
2026-05-28 06:59:51 -04:00
21 changed files with 210456 additions and 153 deletions
+596
View File
@@ -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
View File
@@ -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")
+116
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -8,6 +8,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
@@ -95,12 +96,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
}
@@ -111,8 +111,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
@@ -134,30 +139,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()
}