package controlapi import ( "context" "crypto/sha256" "encoding/json" "errors" "net/http" "strings" "time" "yovision/sense/internal/auth" "yovision/sense/internal/device" "yovision/sense/internal/store" ) type principalContextKey struct{} type traceContextKey struct{} type Server struct { store store.ControlRepository authenticator auth.Authenticator cursors *CursorCodec now func() time.Time } func NewHTTPHandler( repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec, ) http.Handler { server := &Server{ store: repository, authenticator: authenticator, cursors: cursors, now: time.Now, } generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError}) return server.authenticate(generated) } func (s *Server) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { traceID, err := newTraceID() if err != nil { http.Error(writer, "service unavailable", http.StatusServiceUnavailable) return } ctx := context.WithValue(request.Context(), traceContextKey{}, traceID) request = request.WithContext(ctx) values := request.Header.Values("Authorization") if len(values) != 1 { writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`) s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required") return } parts := strings.Fields(values[0]) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`) s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required") return } principal, err := s.authenticator.Authenticate(request.Context(), parts[1]) if err != nil { writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`) s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required") return } ctx = context.WithValue(request.Context(), principalContextKey{}, principal) next.ServeHTTP(writer, request.WithContext(ctx)) }) } func principalFromContext(ctx context.Context) auth.Principal { value, _ := ctx.Value(principalContextKey{}).(auth.Principal) return value } func traceFromContext(ctx context.Context) string { value, _ := ctx.Value(traceContextKey{}).(string) return value } func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) { var required *RequiredHeaderError if errors.As(err, &required) && required.ParamName == "If-Match" { s.writeProblem(writer, request, http.StatusPreconditionRequired, ErrorCodePreconditionRequired, "If-Match is required") return } s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid") } func (s *Server) requireSite( writer http.ResponseWriter, request *http.Request, siteID, permission string, ) (auth.Principal, bool) { principal := principalFromContext(request.Context()) if !validLogicalID(siteID) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid") return auth.Principal{}, false } if !principal.AllowsSite(siteID) { s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found") return auth.Principal{}, false } if !principal.Has(permission) { s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required") return auth.Principal{}, false } return principal, true } func (s *Server) writeProblem( writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string, ) { traceID := traceFromContext(request.Context()) writer.Header().Set("Content-Type", "application/problem+json") writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("X-Trace-ID", traceID) writer.WriteHeader(status) _ = json.NewEncoder(writer).Encode(Problem{ Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status, Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{}, }) } func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) { writer.Header().Set("Content-Type", "application/json") writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("X-Trace-ID", traceID) writer.WriteHeader(status) _ = json.NewEncoder(writer).Encode(value) } func (s *Server) ListDevices( writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead) if !ok { return } limit := 50 if params.Limit != nil { limit = *params.Limit } if limit < 1 || limit > 100 || (params.Modality != nil && !Modality(*params.Modality).Valid()) || (params.Capability != nil && !Capability(*params.Capability).Valid()) || (params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) || (params.ActualState != nil && !ActualState(*params.ActualState).Valid()) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid") return } filterHash := filterFingerprint( optionalString(params.Modality), optionalString(params.Capability), optionalString(params.DesiredState), optionalString(params.ActualState), ) filter := store.ControlListFilter{Limit: limit} if params.Modality != nil { value := device.Modality(*params.Modality) filter.Modality = &value } if params.Capability != nil { value := device.Capability(*params.Capability) filter.Capability = &value } if params.DesiredState != nil { value := device.DesiredState(*params.DesiredState) filter.DesiredState = &value } if params.ActualState != nil { value := device.ActualState(*params.ActualState) filter.ActualState = &value } if params.Cursor != nil { position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash) if err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid") return } filter.AfterCreated = &position.CreatedAt filter.AfterDeviceID = position.DeviceID } page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter) if err != nil { s.writeStoreError(writer, request, err, false) return } items := make([]Device, 0, len(page.Items)) for _, value := range page.Items { items = append(items, publicDevice(value)) } var nextCursor *string if page.HasMore && len(page.Items) > 0 { last := page.Items[len(page.Items)-1] value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash, CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID}) if err != nil { s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created") return } nextCursor = &value } writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{ Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor}, Quota: publicQuota(page.Quota), }) } func optionalString[T ~string](value *T) string { if value == nil { return "" } return string(*value) } func (s *Server) CreateDevice( writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite) if !ok { return } if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid") return } contents, err := readRequestBody(request, "application/json") if err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } if err := rejectTopLevelNulls(contents); err != nil { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error()) return } var body DeviceCreate if err := decodeStrictJSON(contents, &body); err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } if code, message := validateDeviceCreate(body); code != "" { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message) return } desired := Disabled if body.DesiredState != nil { desired = *body.DesiredState } body.DesiredState = &desired canonical, _ := json.Marshal(body) requestHash := sha256.Sum256(canonical) now := s.now().UTC() deviceID, err := newULID("dev_", now) if err != nil { s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated") return } capabilities := make([]device.Capability, len(body.Capabilities)) for index := range body.Capabilities { capabilities[index] = device.Capability(body.Capabilities[index]) } value := device.Device{ ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId, SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality), Capabilities: capabilities, DesiredState: device.DesiredState(desired), ActualState: device.ActualPending, PathName: "devices/" + deviceID, Generation: 1, ResourceVersion: 1, } if body.EndpointRef != nil { value.EndpointRef = *body.EndpointRef } if body.CredentialRef != nil { value.CredentialRef = *body.CredentialRef } if body.ProfileToken != nil { value.ProfileToken = *body.ProfileToken } traceID := traceFromContext(request.Context()) ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID)) result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{ Scope: store.IdempotencyScope{ PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID, Operation: "createDevice", Key: params.IdempotencyKey, RequestHash: requestHash, TraceID: traceID, }, Device: value, }) if err != nil { s.writeStoreError(writer, request, err, true) return } writer.Header().Set("ETag", result.ETag) writer.Header().Set("Location", result.Location) writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device)) } func validateDeviceCreate(body DeviceCreate) (string, string) { if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) || !validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" || strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() || len(body.Capabilities) < 1 || len(body.Capabilities) > 16 { return "invalid_request", "device fields are invalid" } seen := make(map[Capability]struct{}, len(body.Capabilities)) hasVideo := false for _, capability := range body.Capabilities { if !capability.Valid() { return "invalid_request", "device capability is invalid" } if _, exists := seen[capability]; exists { return "invalid_request", "device capabilities contain a duplicate" } seen[capability] = struct{}{} hasVideo = hasVideo || capability == VideoCapture } if body.Modality == Video && !hasVideo { return "invalid_request", "video modality requires video_capture" } if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) { return "adapter_not_ready", "video capture requires endpoint and credential references" } if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) { return "endpoint_credentials_forbidden", "endpoint reference is invalid" } if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") { return "endpoint_credentials_forbidden", "credential reference is invalid" } if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) { return "invalid_request", "profile token is invalid" } if body.DesiredState != nil && !body.DesiredState.Valid() { return "invalid_request", "desired_state is invalid" } return "", "" } func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext { actorType := store.AuditActorService if principal.ActorType == "user" { actorType = store.AuditActorUser } return store.AuditContext{ ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID, } } func (s *Server) GetDevice( writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead) if !ok { return } if !validLogicalID(deviceID) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid") return } value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID) if err != nil { s.writeStoreError(writer, request, err, false) return } writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion)) writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value)) } func (s *Server) UpdateDevice( writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID, params UpdateDeviceParams, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite) if !ok { return } if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid") return } contents, err := readRequestBody(request, "application/merge-patch+json") if err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } var fields map[string]json.RawMessage if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field") return } for _, value := range fields { if string(value) == "null" { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null") return } } var body DevicePatch if err := decodeStrictJSON(contents, &body); err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } if code, message := validateDevicePatch(body); code != "" { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message) return } patch := store.ControlPatch{ Name: body.Name, EndpointRef: body.EndpointRef, CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken, } if body.AreaId != nil { value := string(*body.AreaId) patch.AreaID = &value } traceID := traceFromContext(request.Context()) ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID)) result, err := s.store.PatchControlDevice( ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch, ) if err != nil { s.writeStoreError(writer, request, err, true) return } writer.Header().Set("ETag", result.ETag) writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{ Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID, }) } func validateDevicePatch(body DevicePatch) (string, string) { if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") { return "invalid_request", "name is invalid" } if body.AreaId != nil && !validLogicalID(*body.AreaId) { return "invalid_request", "area_id is invalid" } if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) { return "endpoint_credentials_forbidden", "endpoint reference is invalid" } if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") { return "endpoint_credentials_forbidden", "credential reference is invalid" } if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) { return "invalid_request", "profile token is invalid" } return "", "" } func (s *Server) SetDeviceDesiredState( writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID, params SetDeviceDesiredStateParams, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite) if !ok { return } if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid") return } contents, err := readRequestBody(request, "application/json") if err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } var body DesiredStateChange if err := decodeStrictJSON(contents, &body); err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid") return } traceID := traceFromContext(request.Context()) ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID)) result, err := s.store.SetControlDesiredState( ctx, principal.TenantID, siteID, deviceID, params.IfMatch, device.DesiredState(body.DesiredState), ) if err != nil { s.writeStoreError(writer, request, err, true) return } writer.Header().Set("ETag", result.ETag) writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{ Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID, }) } func (s *Server) BatchSetDeviceDesiredState( writer http.ResponseWriter, request *http.Request, siteID SiteID, params BatchSetDeviceDesiredStateParams, ) { principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite) if !ok { return } if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid") return } contents, err := readRequestBody(request, "application/json") if err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } var body BatchDesiredStateRequest if err := decodeStrictJSON(contents, &body); err != nil { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error()) return } if len(body.Items) > 128 { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items") return } if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid") return } items := make([]store.ControlBatchItem, 0, len(body.Items)) for _, item := range body.Items { if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() { s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid") return } items = append(items, store.ControlBatchItem{ DeviceID: item.DeviceId, ETag: item.Etag, DesiredState: device.DesiredState(item.DesiredState), }) } canonical, _ := json.Marshal(body) requestHash := sha256.Sum256(canonical) traceID := traceFromContext(request.Context()) ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID)) operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{ Scope: store.IdempotencyScope{ PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID, Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey, RequestHash: requestHash, TraceID: traceID, }, Reason: body.Reason, Items: items, }) if err != nil { s.writeStoreError(writer, request, err, true) return } writer.Header().Set("Location", "/api/v1/operations/"+operation.ID) writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation)) } func (s *Server) GetOperation( writer http.ResponseWriter, request *http.Request, operationID OperationID, ) { principal := principalFromContext(request.Context()) if !principal.Has(auth.PermissionDevicesRead) { s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required") return } if !operationIDRegex.MatchString(operationID) { s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid") return } operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID) if err != nil { s.writeStoreError(writer, request, err, false) return } if !principal.AllowsSite(operation.SiteID) { s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found") return } writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation)) } func (s *Server) writeStoreError( writer http.ResponseWriter, request *http.Request, err error, mutation bool, ) { status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed" switch { case errors.Is(err, store.ErrNotFound): status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found" case errors.Is(err, store.ErrETagMismatch): status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match" case errors.Is(err, store.ErrIdempotencyConflict): status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request" case errors.Is(err, store.ErrDuplicateSerialNumber): status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site" case errors.Is(err, store.ErrAreaPolicyDenied): status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change" case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid): status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable" case errors.Is(err, store.ErrQuotaProjectionUnavailable): status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable" case errors.Is(err, store.ErrQuotaProjectionInvalid): status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid" default: var quotaError *device.QuotaExceededError if errors.As(err, "aError) { status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded" } else if mutation { status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted" } } s.writeProblem(writer, request, status, code, message) } var _ ServerInterface = (*Server)(nil)