// 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 }