generated from CubeCraft-Creations/Tracehound
1f253283f8
- 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
117 lines
3.4 KiB
Go
117 lines
3.4 KiB
Go
// 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
|
|
}
|