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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user