diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 70345e53..eeed31b8 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -2,7 +2,9 @@ package api import ( "context" + "errors" "fmt" + "os" "time" "github.com/onkernel/kernel-images/server/lib/logger" @@ -92,7 +94,7 @@ func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRe rec, exists := s.recordManager.GetRecorder(recorderID) if !exists { - log.Warn("attempted to stop recording when none is active", "recorder_id", recorderID) + log.Error("attempted to stop recording when none is active", "recorder_id", recorderID) return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil } else if !rec.IsRecording(ctx) { log.Warn("recording already stopped", "recorder_id", recorderID) @@ -140,6 +142,10 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec log.Error("attempted to download non-existent recording", "recorder_id", recorderID) return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil } + if rec.IsDeleted(ctx) { + log.Error("attempted to download deleted recording", "recorder_id", recorderID) + return oapi.DownloadRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "requested recording has been deleted"}}, nil + } out, meta, err := rec.Recording(ctx) if err != nil { @@ -167,6 +173,36 @@ func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRec }, nil } +func (s *ApiService) DeleteRecording(ctx context.Context, req oapi.DeleteRecordingRequestObject) (oapi.DeleteRecordingResponseObject, error) { + log := logger.FromContext(ctx) + + recorderID := s.defaultRecorderID + if req.Body != nil && req.Body.Id != nil && *req.Body.Id != "" { + recorderID = *req.Body.Id + } + rec, exists := s.recordManager.GetRecorder(recorderID) + if !exists { + log.Error("attempted to delete non-existent recording", "recorder_id", recorderID) + return oapi.DeleteRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil + } + + if rec.IsRecording(ctx) { + log.Error("attempted to delete recording while still in progress", "recorder_id", recorderID) + return oapi.DeleteRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording must be stopped first"}}, nil + } + + // fine to do this async + go func() { + if err := rec.Delete(context.Background()); err != nil && !errors.Is(err, os.ErrNotExist) { + log.Error("failed to delete recording", "err", err, "recorder_id", recorderID) + } else { + log.Info("recording deleted", "recorder_id", recorderID) + } + }() + + return oapi.DeleteRecording200Response{}, nil +} + // ListRecorders returns a list of all registered recorders and whether each one is currently recording. func (s *ApiService) ListRecorders(ctx context.Context, _ oapi.ListRecordersRequestObject) (oapi.ListRecordersResponseObject, error) { infos := []oapi.RecorderInfo{} diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index 76beb446..1d7cf206 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "math/rand" + "os" "testing" oapi "github.com/onkernel/kernel-images/server/lib/oapi" @@ -217,6 +218,7 @@ type mockRecorder struct { forceStopErr error recordingErr error recordingData []byte + deleted bool } func (m *mockRecorder) ID() string { return m.id } @@ -251,6 +253,9 @@ func (m *mockRecorder) ForceStop(ctx context.Context) error { func (m *mockRecorder) IsRecording(ctx context.Context) bool { return m.isRecordingFlag } func (m *mockRecorder) Recording(ctx context.Context) (io.ReadCloser, *recorder.RecordingMetadata, error) { + if m.deleted { + return nil, nil, fmt.Errorf("deleted: %w", os.ErrNotExist) + } if m.recordingErr != nil { return nil, nil, m.recordingErr } @@ -263,6 +268,16 @@ func (m *mockRecorder) Metadata() *recorder.RecordingMetadata { return &recorder.RecordingMetadata{} } +func (m *mockRecorder) Delete(ctx context.Context) error { + if m.isRecordingFlag { + return fmt.Errorf("still recording") + } + m.deleted = true + return nil +} + +func (m *mockRecorder) IsDeleted(ctx context.Context) bool { return m.deleted } + func newMockFactory() recorder.FFmpegRecorderFactory { return func(id string, _ recorder.FFmpegRecordingParams) (recorder.Recorder, error) { rec := &mockRecorder{id: id} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index cbd77d51..5adf665d 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -66,6 +66,12 @@ type ClickMouseRequestButton string // ClickMouseRequestClickType Type of click action type ClickMouseRequestClickType string +// DeleteRecordingRequest defines model for DeleteRecordingRequest. +type DeleteRecordingRequest struct { + // Id Identifier of the recording to delete. Alphanumeric or hyphen. + Id *string `json:"id,omitempty"` +} + // Error defines model for Error. type Error struct { Message string `json:"message"` @@ -142,6 +148,9 @@ type ClickMouseJSONRequestBody = ClickMouseRequest // MoveMouseJSONRequestBody defines body for MoveMouse for application/json ContentType. type MoveMouseJSONRequestBody = MoveMouseRequest +// DeleteRecordingJSONRequestBody defines body for DeleteRecording for application/json ContentType. +type DeleteRecordingJSONRequestBody = DeleteRecordingRequest + // StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. type StartRecordingJSONRequestBody = StartRecordingRequest @@ -231,6 +240,11 @@ type ClientInterface interface { MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteRecordingWithBody request with any body + DeleteRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + DeleteRecording(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DownloadRecording request DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -296,6 +310,30 @@ func (c *Client) MoveMouse(ctx context.Context, body MoveMouseJSONRequestBody, r return c.Client.Do(req) } +func (c *Client) DeleteRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteRecordingRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteRecording(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteRecordingRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DownloadRecording(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDownloadRecordingRequest(c.Server, params) if err != nil { @@ -448,6 +486,46 @@ func NewMoveMouseRequestWithBody(server string, contentType string, body io.Read return req, nil } +// NewDeleteRecordingRequest calls the generic DeleteRecording builder with application/json body +func NewDeleteRecordingRequest(server string, body DeleteRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDeleteRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewDeleteRecordingRequestWithBody generates requests for DeleteRecording with any type of body +func NewDeleteRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/delete") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewDownloadRecordingRequest generates requests for DownloadRecording func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) (*http.Request, error) { var err error @@ -657,6 +735,11 @@ type ClientWithResponsesInterface interface { MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + // DeleteRecordingWithBodyWithResponse request with any body + DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) + + DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) + // DownloadRecordingWithResponse request DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) @@ -720,6 +803,30 @@ func (r MoveMouseResponse) StatusCode() int { return 0 } +type DeleteRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DeleteRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DownloadRecordingResponse struct { Body []byte HTTPResponse *http.Response @@ -848,6 +955,23 @@ func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body Mo return ParseMoveMouseResponse(rsp) } +// DeleteRecordingWithBodyWithResponse request with arbitrary body returning *DeleteRecordingResponse +func (c *ClientWithResponses) DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { + rsp, err := c.DeleteRecordingWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteRecordingResponse(rsp) +} + +func (c *ClientWithResponses) DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { + rsp, err := c.DeleteRecording(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteRecordingResponse(rsp) +} + // DownloadRecordingWithResponse request returning *DownloadRecordingResponse func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { rsp, err := c.DownloadRecording(ctx, params, reqEditors...) @@ -966,6 +1090,46 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { return response, nil } +// ParseDeleteRecordingResponse parses an HTTP response from a DeleteRecordingWithResponse call +func ParseDeleteRecordingResponse(rsp *http.Response) (*DeleteRecordingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteRecordingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseDownloadRecordingResponse parses an HTTP response from a DownloadRecordingWithResponse call func ParseDownloadRecordingResponse(rsp *http.Response) (*DownloadRecordingResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -1120,6 +1284,9 @@ type ServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(w http.ResponseWriter, r *http.Request) + // Delete a previously recorded video file + // (POST /recording/delete) + DeleteRecording(w http.ResponseWriter, r *http.Request) // Download the most recently recorded video file // (GET /recording/download) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) @@ -1150,6 +1317,12 @@ func (_ Unimplemented) MoveMouse(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Delete a previously recorded video file +// (POST /recording/delete) +func (_ Unimplemented) DeleteRecording(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Download the most recently recorded video file // (GET /recording/download) func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { @@ -1211,6 +1384,20 @@ func (siw *ServerInterfaceWrapper) MoveMouse(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// DeleteRecording operation middleware +func (siw *ServerInterfaceWrapper) DeleteRecording(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteRecording(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DownloadRecording operation middleware func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *http.Request) { @@ -1399,6 +1586,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/computer/move_mouse", wrapper.MoveMouse) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/recording/delete", wrapper.DeleteRecording) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/recording/download", wrapper.DownloadRecording) }) @@ -1491,6 +1681,49 @@ func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } +type DeleteRecordingRequestObject struct { + Body *DeleteRecordingJSONRequestBody +} + +type DeleteRecordingResponseObject interface { + VisitDeleteRecordingResponse(w http.ResponseWriter) error +} + +type DeleteRecording200Response struct { +} + +func (response DeleteRecording200Response) VisitDeleteRecordingResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response DeleteRecording400JSONResponse) VisitDeleteRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteRecording404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response DeleteRecording404JSONResponse) VisitDeleteRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteRecording500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DeleteRecording500JSONResponse) VisitDeleteRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type DownloadRecordingRequestObject struct { Params DownloadRecordingParams } @@ -1677,6 +1910,9 @@ type StrictServerInterface interface { // Move the mouse cursor to the specified coordinates on the host computer // (POST /computer/move_mouse) MoveMouse(ctx context.Context, request MoveMouseRequestObject) (MoveMouseResponseObject, error) + // Delete a previously recorded video file + // (POST /recording/delete) + DeleteRecording(ctx context.Context, request DeleteRecordingRequestObject) (DeleteRecordingResponseObject, error) // Download the most recently recorded video file // (GET /recording/download) DownloadRecording(ctx context.Context, request DownloadRecordingRequestObject) (DownloadRecordingResponseObject, error) @@ -1782,6 +2018,37 @@ func (sh *strictHandler) MoveMouse(w http.ResponseWriter, r *http.Request) { } } +// DeleteRecording operation middleware +func (sh *strictHandler) DeleteRecording(w http.ResponseWriter, r *http.Request) { + var request DeleteRecordingRequestObject + + var body DeleteRecordingJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DeleteRecording(ctx, request.(DeleteRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DeleteRecordingResponseObject); ok { + if err := validResponse.VisitDeleteRecordingResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DownloadRecording operation middleware func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request, params DownloadRecordingParams) { var request DownloadRecordingRequestObject @@ -1897,33 +2164,34 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "xeGFqmAYAAA=", + "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", + "AA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 101a8b69..688b6630 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -46,6 +46,7 @@ type FFmpegRecorder struct { ffmpegErr error exitCode int exited chan struct{} + deleted bool stz *scaletozero.Oncer } @@ -225,6 +226,12 @@ func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool { return fr.cmd != nil && fr.exitCode < exitCodeProcessDoneMinValue } +func (fr *FFmpegRecorder) IsDeleted(ctx context.Context) bool { + fr.mu.Lock() + defer fr.mu.Unlock() + return fr.deleted +} + // Metadata is an incomplete snapshot of the recording metadata. func (fr *FFmpegRecorder) Metadata() *RecordingMetadata { fr.mu.Lock() @@ -238,6 +245,13 @@ func (fr *FFmpegRecorder) Metadata() *RecordingMetadata { // Recording returns the recording file as an io.ReadCloser. func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) { + fr.mu.Lock() + if fr.deleted { + fr.mu.Unlock() + return nil, nil, fmt.Errorf("recording deleted: %w", os.ErrNotExist) + } + fr.mu.Unlock() + file, err := os.Open(fr.outputPath) if err != nil { return nil, nil, fmt.Errorf("failed to open recording file: %w", err) @@ -259,6 +273,21 @@ func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *Record }, nil } +// Delete removes the recording file from disk +func (fr *FFmpegRecorder) Delete(ctx context.Context) error { + fr.mu.Lock() + defer fr.mu.Unlock() + if fr.deleted { + return nil // already deleted + } + if err := os.Remove(fr.outputPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete recording file: %w", err) + } + + fr.deleted = true + return nil +} + // ffmpegArgs generates platform-specific ffmpeg command line arguments. Allegedly order matters. func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { var args []string @@ -436,7 +465,9 @@ func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []Recorder { recorders := make([]Recorder, 0, len(fm.recorders)) for _, recorder := range fm.recorders { - recorders = append(recorders, recorder) + if !recorder.IsDeleted(ctx) { + recorders = append(recorders, recorder) + } } return recorders diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go index f888b2d5..8b3b53e3 100644 --- a/server/lib/recorder/recorder.go +++ b/server/lib/recorder/recorder.go @@ -14,7 +14,9 @@ type Recorder interface { ForceStop(ctx context.Context) error IsRecording(ctx context.Context) bool Metadata() *RecordingMetadata - Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) // Returns the recording file as a ReadCloser + Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) + Delete(ctx context.Context) error + IsDeleted(ctx context.Context) bool } type RecordingMetadata struct { diff --git a/server/openapi.yaml b/server/openapi.yaml index da8727da..6f9584e0 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -101,6 +101,25 @@ paths: $ref: "#/components/schemas/RecorderInfo" "500": $ref: "#/components/responses/InternalError" + /recording/delete: + post: + summary: Delete a previously recorded video file + operationId: deleteRecording + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteRecordingRequest" + responses: + "200": + description: Recording deleted + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" /computer/click_mouse: post: summary: Simulate a mouse click action on the host computer @@ -243,6 +262,14 @@ components: items: type: string additionalProperties: false + DeleteRecordingRequest: + type: object + properties: + id: + type: string + description: Identifier of the recording to delete. Alphanumeric or hyphen. + pattern: "^[a-zA-Z0-9-]+$" + additionalProperties: false responses: BadRequestError: description: Bad Request