diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml new file mode 100644 index 00000000..0c02e7c7 --- /dev/null +++ b/.github/workflows/server-test.yaml @@ -0,0 +1,25 @@ +name: Test for the server/ directory + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "server/go.mod" + cache: true + + - name: Run server Makefile tests + run: make test + working-directory: server diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 00000000..031de437 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,34 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# End of https://www.toptal.com/developers/gitignore/api/go + +.tmp/ +bin/ +recordings/ + +# downconverted openapi spec +openapi-3.0.yaml diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 00000000..606ab870 --- /dev/null +++ b/server/Makefile @@ -0,0 +1,40 @@ +SHELL := /bin/bash +.PHONY: oapi-generate build dev test clean + +BIN_DIR ?= $(CURDIR)/bin +OAPI_CODEGEN ?= $(BIN_DIR)/oapi-codegen +RECORDING_DIR ?= $(CURDIR)/recordings + +$(BIN_DIR): + mkdir -p $(BIN_DIR) + +$(RECORDING_DIR): + mkdir -p $(RECORDING_DIR) + +$(OAPI_CODEGEN): | $(BIN_DIR) + GOBIN=$(BIN_DIR) go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest + +# Generate Go code from the OpenAPI spec +# 1. Convert 3.1 โ†’ 3.0 since oapi-codegen doesn't support 3.1 yet (https://github.com/oapi-codegen/oapi-codegen/issues/373) +# 2. Run oapi-codegen with our config +# 3. go mod tidy to pull deps +oapi-generate: $(OAPI_CODEGEN) + pnpm i -g @apiture/openapi-down-convert + openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml + $(OAPI_CODEGEN) -config ./oapi-codegen.yaml ./openapi-3.0.yaml + go mod tidy + +build: | $(BIN_DIR) + go build -o $(BIN_DIR)/api ./cmd/api + +dev: build $(RECORDING_DIR) + OUTPUT_DIR=$(RECORDING_DIR) ./bin/api + +test: + go vet ./... + go test -v -race ./... + +clean: + @rm -rf $(BIN_DIR) + @rm -f openapi-3.0.yaml + @echo "Clean complete" diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..6da30768 --- /dev/null +++ b/server/README.md @@ -0,0 +1,90 @@ +# Kernel Images Server + +A REST API server to start, stop, and download screen recordings. + +## ๐Ÿ› ๏ธ Prerequisites + +### Required Software + +- **Go 1.24.3+** - Programming language runtime +- **FFmpeg** - Video recording engine + - macOS: `brew install ffmpeg` + - Linux: `sudo apt install ffmpeg` or `sudo yum install ffmpeg` +- **Node.js/pnpm** - For OpenAPI code generation + - `npm install -g pnpm` + +### System Requirements + +- **macOS**: Uses AVFoundation for screen capture +- **Linux**: Uses X11 for screen capture +- **Windows**: Not currently supported + +## ๐Ÿš€ Quick Start + +### Running the Server + +```bash +make dev +``` + +The server will start on port 10001 by default and log its configuration. + +#### Example use + +```bash +# 1. Start a new recording +curl http://localhost:10001/recording/start + +# (recording in progress) + +# 2. Stop recording and clean up resources +curl http://localhost:10001/recording/stop + +# 3. Download the recorded file +curl http://localhost:10001/recording/download --output foo.mp4 +``` + +### โš™๏ธ Configuration + +Configure the server using environment variables: + +| Variable | Default | Description | +| ------------- | ------- | --------------------------------- | +| `PORT` | `10001` | HTTP server port | +| `FRAME_RATE` | `10` | Default recording framerate (fps) | +| `DISPLAY_NUM` | `1` | Display/screen number to capture | +| `MAX_SIZE_MB` | `500` | Default maximum file size (MB) | +| `OUTPUT_DIR` | `.` | Directory to save recordings | + +#### Example Configuration + +```bash +export PORT=8080 +export FRAME_RATE=30 +export MAX_SIZE_MB=1000 +export OUTPUT_DIR=/tmp/recordings +./bin/api +``` + +### API Documentation + +- **YAML Spec**: `GET /spec.yaml` +- **JSON Spec**: `GET /spec.json` + +## ๐Ÿ”ง Development + +### Code Generation + +The server uses OpenAPI code generation. After modifying `openapi.yaml`: + +```bash +make oapi-generate +``` + +## ๐Ÿงช Testing + +### Running Tests + +```bash +make test +``` diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go new file mode 100644 index 00000000..f0106b25 --- /dev/null +++ b/server/cmd/api/api/api.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + + "github.com/onkernel/kernel-images/server/lib/logger" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/recorder" +) + +// ApiService implements the API endpoints +// It manages a single recording session and provides endpoints for starting, stopping, and downloading it +type ApiService struct { + mainRecorderID string // ID used for the primary recording session + recordManager recorder.RecordManager + factory recorder.FFmpegRecorderFactory +} + +func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService { + return &ApiService{ + recordManager: recordManager, + factory: factory, + mainRecorderID: "main", // use a single recorder for now + } +} + +func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) { + log := logger.FromContext(ctx) + + if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) { + log.Error("attempted to start recording while one is already active") + return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil + } + + var params recorder.FFmpegRecordingParams + if req.Body != nil { + params.FrameRate = req.Body.Framerate + params.MaxSizeInMB = req.Body.MaxFileSizeInMB + } + + // Create, register, and start a new recorder + rec, err := s.factory(s.mainRecorderID, params) + if err != nil { + log.Error("failed to create recorder", "err", err) + return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil + } + if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil { + log.Error("failed to register recorder", "err", err) + return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil + } + + if err := rec.Start(ctx); err != nil { + log.Error("failed to start recording", "err", err) + // ensure the recorder is deregistered if we fail to start + defer s.recordManager.DeregisterRecorder(ctx, rec) + return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil + } + + return oapi.StartRecording201Response{}, nil +} + +func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRequestObject) (oapi.StopRecordingResponseObject, error) { + log := logger.FromContext(ctx) + + rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) + if !exists || !rec.IsRecording(ctx) { + log.Warn("attempted to stop recording when none is active") + return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil + } + + // Check if force stop is requested + forceStop := false + if req.Body != nil && req.Body.ForceStop != nil { + forceStop = *req.Body.ForceStop + } + + var err error + if forceStop { + log.Info("force stopping recording") + err = rec.ForceStop(ctx) + } else { + log.Info("gracefully stopping recording") + err = rec.Stop(ctx) + } + + if err != nil { + log.Error("error occurred while stopping recording", "err", err, "force", forceStop) + } + + return oapi.StopRecording200Response{}, nil +} + +func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) { + log := logger.FromContext(ctx) + + // Get the recorder to access its output path + rec, exists := s.recordManager.GetRecorder(s.mainRecorderID) + if !exists { + log.Error("attempted to download non-existent recording") + return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil + } + + if rec.IsRecording(ctx) { + log.Warn("attempted to download recording while is still in progress") + return oapi.DownloadRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording still in progress, please stop first"}}, nil + } + + out, meta, err := rec.Recording(ctx) + if err != nil { + log.Error("failed to get recording", "err", err) + return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil + } + + log.Info("serving recording file for download", "size", meta.Size) + return oapi.DownloadRecording200Videomp4Response{ + Body: out, + ContentLength: meta.Size, + }, nil +} + +func (s *ApiService) Shutdown(ctx context.Context) error { + return s.recordManager.StopAll(ctx) +} diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go new file mode 100644 index 00000000..01d0dc75 --- /dev/null +++ b/server/cmd/api/api/api_test.go @@ -0,0 +1,201 @@ +package api + +import ( + "bytes" + "context" + "io" + "testing" + + oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/recorder" + "github.com/stretchr/testify/require" +) + +func TestApiService_StartRecording(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc := New(mgr, newMockFactory()) + + resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.StartRecording201Response{}, resp) + + rec, exists := mgr.GetRecorder("main") + require.True(t, exists, "recorder was not registered") + require.True(t, rec.IsRecording(ctx), "recorder should be recording after Start") + }) + + t.Run("already recording", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc := New(mgr, newMockFactory()) + + // First start should succeed + _, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) + require.NoError(t, err) + + // Second start should return conflict + resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.StartRecording409JSONResponse{}, resp) + }) +} + +func TestApiService_StopRecording(t *testing.T) { + ctx := context.Background() + + t.Run("no active recording", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc := New(mgr, newMockFactory()) + + resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.StopRecording400JSONResponse{}, resp) + }) + + t.Run("graceful stop", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "main", isRecordingFlag: true} + require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") + + svc := New(mgr, newMockFactory()) + resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.StopRecording200Response{}, resp) + require.True(t, rec.stopCalled, "Stop should have been called on recorder") + }) + + t.Run("force stop", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "main", isRecordingFlag: true} + require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") + + force := true + req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} + svc := New(mgr, newMockFactory()) + resp, err := svc.StopRecording(ctx, req) + require.NoError(t, err) + require.IsType(t, oapi.StopRecording200Response{}, resp) + require.True(t, rec.forceStopCalled, "ForceStop should have been called on recorder") + }) +} + +func TestApiService_DownloadRecording(t *testing.T) { + ctx := context.Background() + + t.Run("not found", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + svc := New(mgr, newMockFactory()) + resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.DownloadRecording404JSONResponse{}, resp) + }) + + t.Run("still recording", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "main", isRecordingFlag: true} + require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") + + svc := New(mgr, newMockFactory()) + resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.DownloadRecording400JSONResponse{}, resp) + }) + + t.Run("success", func(t *testing.T) { + mgr := recorder.NewFFmpegManager() + data := []byte("dummy video data") + rec := &mockRecorder{id: "main", recordingData: data} + require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") + + svc := New(mgr, newMockFactory()) + resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) + require.NoError(t, err) + r, ok := resp.(oapi.DownloadRecording200Videomp4Response) + require.True(t, ok, "expected 200 mp4 response, got %T", resp) + buf := new(bytes.Buffer) + _, copyErr := io.Copy(buf, r.Body) + require.NoError(t, copyErr) + require.Equal(t, data, buf.Bytes(), "response body mismatch") + require.Equal(t, int64(len(data)), r.ContentLength, "content length mismatch") + }) +} + +func TestApiService_Shutdown(t *testing.T) { + ctx := context.Background() + mgr := recorder.NewFFmpegManager() + rec := &mockRecorder{id: "main", isRecordingFlag: true} + require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") + + svc := New(mgr, newMockFactory()) + + require.NoError(t, svc.Shutdown(ctx)) + require.True(t, rec.stopCalled, "Shutdown should have stopped active recorder") +} + +// mockRecorder is a lightweight stand-in for recorder.Recorder used in unit tests. It purposefully +// keeps the behaviour minimal โ€“ just enough to satisfy ApiService logic. All public methods are +// safe for single-goroutine unit-test access. +type mockRecorder struct { + id string + isRecordingFlag bool + + startCalled bool + stopCalled bool + forceStopCalled bool + + // configurable behaviours + startErr error + stopErr error + forceStopErr error + recordingErr error + recordingData []byte +} + +func (m *mockRecorder) ID() string { return m.id } + +func (m *mockRecorder) Start(ctx context.Context) error { + m.startCalled = true + if m.startErr != nil { + return m.startErr + } + m.isRecordingFlag = true + return nil +} + +func (m *mockRecorder) Stop(ctx context.Context) error { + m.stopCalled = true + if m.stopErr != nil { + return m.stopErr + } + m.isRecordingFlag = false + return nil +} + +func (m *mockRecorder) ForceStop(ctx context.Context) error { + m.forceStopCalled = true + if m.forceStopErr != nil { + return m.forceStopErr + } + m.isRecordingFlag = false + return nil +} + +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.recordingErr != nil { + return nil, nil, m.recordingErr + } + reader := io.NopCloser(bytes.NewReader(m.recordingData)) + meta := &recorder.RecordingMetadata{Size: int64(len(m.recordingData))} + return reader, meta, nil +} + +func newMockFactory() recorder.FFmpegRecorderFactory { + return func(id string, _ recorder.FFmpegRecordingParams) (recorder.Recorder, error) { + rec := &mockRecorder{id: id} + return rec, nil + } +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go new file mode 100644 index 00000000..ec52a9ce --- /dev/null +++ b/server/cmd/api/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/ghodss/yaml" + "github.com/go-chi/chi/v5" + chiMiddleware "github.com/go-chi/chi/v5/middleware" + "golang.org/x/sync/errgroup" + + serverpkg "github.com/onkernel/kernel-images/server" + "github.com/onkernel/kernel-images/server/cmd/api/api" + "github.com/onkernel/kernel-images/server/cmd/config" + "github.com/onkernel/kernel-images/server/lib/logger" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/recorder" +) + +func main() { + slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Load configuration from environment variables + config, err := config.Load() + if err != nil { + slogger.Error("failed to load configuration", "err", err) + os.Exit(1) + } + slogger.Info("server configuration", "config", config) + + // context cancellation on SIGINT/SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // ensure ffmpeg is available + mustFFmpeg() + + r := chi.NewRouter() + r.Use( + chiMiddleware.Logger, + chiMiddleware.Recoverer, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxWithLogger := logger.AddToContext(r.Context(), slogger) + next.ServeHTTP(w, r.WithContext(ctxWithLogger)) + }) + }, + ) + + defaultParams := recorder.FFmpegRecordingParams{ + DisplayNum: &config.DisplayNum, + FrameRate: &config.FrameRate, + MaxSizeInMB: &config.MaxSizeInMB, + OutputDir: &config.OutputDir, + } + if err := defaultParams.Validate(); err != nil { + slogger.Error("invalid default recording parameters", "err", err) + os.Exit(1) + } + apiService := api.New(recorder.NewFFmpegManager(), recorder.NewFFmpegRecorderFactory(config.PathToFFmpeg, defaultParams)) + + strictHandler := oapi.NewStrictHandler(apiService, nil) + oapi.HandlerFromMux(strictHandler, r) + + // endpoints to expose the spec + r.Get("/spec.yaml", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oai.openapi") + w.Write(serverpkg.OpenAPIYAML) + }) + r.Get("/spec.json", func(w http.ResponseWriter, r *http.Request) { + jsonData, err := yaml.YAMLToJSON(serverpkg.OpenAPIYAML) + if err != nil { + http.Error(w, "failed to convert YAML to JSON", http.StatusInternalServerError) + logger.FromContext(r.Context()).Error("failed to convert YAML to JSON", "err", err) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) + }) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", config.Port), + Handler: r, + } + + go func() { + slogger.Info("http server starting", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slogger.Error("http server failed", "err", err) + stop() + } + }() + + // graceful shutdown + <-ctx.Done() + slogger.Info("shutdown signal received") + + g, _ := errgroup.WithContext(ctx) + + g.Go(func() error { + return srv.Shutdown(context.Background()) + }) + g.Go(func() error { + return apiService.Shutdown(ctx) + }) + + if err := g.Wait(); err != nil { + slogger.Error("server failed to shutdown", "err", err) + } +} + +func mustFFmpeg() { + cmd := exec.Command("ffmpeg", "-version") + if err := cmd.Run(); err != nil { + panic(fmt.Errorf("ffmpeg not found or not executable: %w", err)) + } +} diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go new file mode 100644 index 00000000..bff6d2b7 --- /dev/null +++ b/server/cmd/config/config.go @@ -0,0 +1,55 @@ +package config + +import ( + "fmt" + + "github.com/kelseyhightower/envconfig" +) + +// Config holds all configuration for the server +type Config struct { + // Server configuration + Port int `envconfig:"PORT" default:"10001"` + + // Recording configuration + FrameRate int `envconfig:"FRAME_RATE" default:"10"` + DisplayNum int `envconfig:"DISPLAY_NUM" default:"1"` + MaxSizeInMB int `envconfig:"MAX_SIZE_MB" default:"500"` + OutputDir string `envconfig:"OUTPUT_DIR" default:"."` + + // Absolute or relative path to the ffmpeg binary. If empty the code falls back to "ffmpeg" on $PATH. + PathToFFmpeg string `envconfig:"FFMPEG_PATH" default:"ffmpeg"` +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + var config Config + if err := envconfig.Process("", &config); err != nil { + return nil, err + } + if err := validate(&config); err != nil { + return nil, err + } + + return &config, nil +} + +func validate(config *Config) error { + if config.OutputDir == "" { + return fmt.Errorf("OUTPUT_DIR is required") + } + if config.DisplayNum < 0 { + return fmt.Errorf("DISPLAY_NUM must be greater than 0") + } + if config.FrameRate < 0 { + return fmt.Errorf("FRAME_RATE must be greater than 0") + } + if config.MaxSizeInMB < 0 { + return fmt.Errorf("MAX_SIZE_MB must be greater than 0") + } + if config.PathToFFmpeg == "" { + return fmt.Errorf("FFMPEG_PATH is required") + } + + return nil +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 00000000..38925a73 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,28 @@ +module github.com/onkernel/kernel-images/server + +go 1.24.3 + +require ( + github.com/getkin/kin-openapi v0.132.0 + github.com/ghodss/yaml v1.0.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/oapi-codegen/runtime v1.1.1 + github.com/stretchr/testify v1.9.0 + golang.org/x/sync v0.15.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 00000000..d120fb9c --- /dev/null +++ b/server/go.sum @@ -0,0 +1,51 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= +github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/lib/logger/context.go b/server/lib/logger/context.go new file mode 100644 index 00000000..c38c0cbb --- /dev/null +++ b/server/lib/logger/context.go @@ -0,0 +1,21 @@ +package logger + +import ( + "context" + "log/slog" +) + +type contextKey string + +const loggerKey contextKey = "lib-slogger" + +func AddToContext(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, logger) +} + +func FromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok { + return logger + } + return slog.Default() +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go new file mode 100644 index 00000000..51856a62 --- /dev/null +++ b/server/lib/oapi/oapi.go @@ -0,0 +1,1149 @@ +// Package oapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. +package oapi + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" +) + +// Error defines model for Error. +type Error struct { + Message string `json:"message"` +} + +// StartRecordingRequest defines model for StartRecordingRequest. +type StartRecordingRequest struct { + // Framerate Recording framerate in fps (overrides server default) + Framerate *int `json:"framerate,omitempty"` + + // MaxFileSizeInMB Maximum file size in MB (overrides server default) + MaxFileSizeInMB *int `json:"maxFileSizeInMB,omitempty"` +} + +// StopRecordingRequest defines model for StopRecordingRequest. +type StopRecordingRequest struct { + // ForceStop Immediately stop without graceful shutdown. This may result in a corrupted video file. + ForceStop *bool `json:"forceStop,omitempty"` +} + +// BadRequestError defines model for BadRequestError. +type BadRequestError = Error + +// ConflictError defines model for ConflictError. +type ConflictError = Error + +// InternalError defines model for InternalError. +type InternalError = Error + +// NotFoundError defines model for NotFoundError. +type NotFoundError = Error + +// StartRecordingJSONRequestBody defines body for StartRecording for application/json ContentType. +type StartRecordingJSONRequestBody = StartRecordingRequest + +// StopRecordingJSONRequestBody defines body for StopRecording for application/json ContentType. +type StopRecordingJSONRequestBody = StopRecordingRequest + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // DownloadRecording request + DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // StartRecordingWithBody request with any body + StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + StartRecording(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // StopRecordingWithBody request with any body + StopRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) DownloadRecording(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDownloadRecordingRequest(c.Server) + 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) StartRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStartRecordingRequestWithBody(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) StartRecording(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStartRecordingRequest(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) StopRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStopRecordingRequestWithBody(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) StopRecording(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStopRecordingRequest(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) +} + +// NewDownloadRecordingRequest generates requests for DownloadRecording +func NewDownloadRecordingRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/download") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewStartRecordingRequest calls the generic StartRecording builder with application/json body +func NewStartRecordingRequest(server string, body StartRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewStartRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewStartRecordingRequestWithBody generates requests for StartRecording with any type of body +func NewStartRecordingRequestWithBody(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/start") + 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 +} + +// NewStopRecordingRequest calls the generic StopRecording builder with application/json body +func NewStopRecordingRequest(server string, body StopRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewStopRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewStopRecordingRequestWithBody generates requests for StopRecording with any type of body +func NewStopRecordingRequestWithBody(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/stop") + 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 +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // DownloadRecordingWithResponse request + DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + + // StartRecordingWithBodyWithResponse request with any body + StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) + + StartRecordingWithResponse(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) + + // StopRecordingWithBodyWithResponse request with any body + StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) + + StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) +} + +type DownloadRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DownloadRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DownloadRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type StartRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON409 *ConflictError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r StartRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StartRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type StopRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r StopRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StopRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// DownloadRecordingWithResponse request returning *DownloadRecordingResponse +func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { + rsp, err := c.DownloadRecording(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseDownloadRecordingResponse(rsp) +} + +// StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse +func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { + rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStartRecordingResponse(rsp) +} + +func (c *ClientWithResponses) StartRecordingWithResponse(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { + rsp, err := c.StartRecording(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStartRecordingResponse(rsp) +} + +// StopRecordingWithBodyWithResponse request with arbitrary body returning *StopRecordingResponse +func (c *ClientWithResponses) StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { + rsp, err := c.StopRecordingWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStopRecordingResponse(rsp) +} + +func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { + rsp, err := c.StopRecording(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStopRecordingResponse(rsp) +} + +// ParseDownloadRecordingResponse parses an HTTP response from a DownloadRecordingWithResponse call +func ParseDownloadRecordingResponse(rsp *http.Response) (*DownloadRecordingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DownloadRecordingResponse{ + 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 +} + +// ParseStartRecordingResponse parses an HTTP response from a StartRecordingWithResponse call +func ParseStartRecordingResponse(rsp *http.Response) (*StartRecordingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &StartRecordingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ConflictError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &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 +} + +// ParseStopRecordingResponse parses an HTTP response from a StopRecordingWithResponse call +func ParseStopRecordingResponse(rsp *http.Response) (*StopRecordingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &StopRecordingResponse{ + 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 == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Download the most recently recorded video file + // (GET /recording/download) + DownloadRecording(w http.ResponseWriter, r *http.Request) + // Start a screen recording. Only one recording can be active at a time. + // (POST /recording/start) + StartRecording(w http.ResponseWriter, r *http.Request) + // Stop the current recording + // (POST /recording/stop) + StopRecording(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Download the most recently recorded video file +// (GET /recording/download) +func (_ Unimplemented) DownloadRecording(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Start a screen recording. Only one recording can be active at a time. +// (POST /recording/start) +func (_ Unimplemented) StartRecording(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Stop the current recording +// (POST /recording/stop) +func (_ Unimplemented) StopRecording(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// DownloadRecording operation middleware +func (siw *ServerInterfaceWrapper) DownloadRecording(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DownloadRecording(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StartRecording operation middleware +func (siw *ServerInterfaceWrapper) StartRecording(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StartRecording(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StopRecording operation middleware +func (siw *ServerInterfaceWrapper) StopRecording(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StopRecording(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/recording/download", wrapper.DownloadRecording) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/recording/start", wrapper.StartRecording) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) + }) + + return r +} + +type BadRequestErrorJSONResponse Error + +type ConflictErrorJSONResponse Error + +type InternalErrorJSONResponse Error + +type NotFoundErrorJSONResponse Error + +type DownloadRecordingRequestObject struct { +} + +type DownloadRecordingResponseObject interface { + VisitDownloadRecordingResponse(w http.ResponseWriter) error +} + +type DownloadRecording200Videomp4Response struct { + Body io.Reader + ContentLength int64 +} + +func (response DownloadRecording200Videomp4Response) VisitDownloadRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "video/mp4") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type DownloadRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response DownloadRecording400JSONResponse) VisitDownloadRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DownloadRecording404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response DownloadRecording404JSONResponse) VisitDownloadRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DownloadRecording500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DownloadRecording500JSONResponse) VisitDownloadRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type StartRecordingRequestObject struct { + Body *StartRecordingJSONRequestBody +} + +type StartRecordingResponseObject interface { + VisitStartRecordingResponse(w http.ResponseWriter) error +} + +type StartRecording201Response struct { +} + +func (response StartRecording201Response) VisitStartRecordingResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type StartRecording409JSONResponse struct{ ConflictErrorJSONResponse } + +func (response StartRecording409JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type StartRecording500JSONResponse struct{ InternalErrorJSONResponse } + +func (response StartRecording500JSONResponse) VisitStartRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type StopRecordingRequestObject struct { + Body *StopRecordingJSONRequestBody +} + +type StopRecordingResponseObject interface { + VisitStopRecordingResponse(w http.ResponseWriter) error +} + +type StopRecording200Response struct { +} + +func (response StopRecording200Response) VisitStopRecordingResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type StopRecording400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response StopRecording400JSONResponse) VisitStopRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type StopRecording500JSONResponse struct{ InternalErrorJSONResponse } + +func (response StopRecording500JSONResponse) VisitStopRecordingResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Download the most recently recorded video file + // (GET /recording/download) + DownloadRecording(ctx context.Context, request DownloadRecordingRequestObject) (DownloadRecordingResponseObject, error) + // Start a screen recording. Only one recording can be active at a time. + // (POST /recording/start) + StartRecording(ctx context.Context, request StartRecordingRequestObject) (StartRecordingResponseObject, error) + // Stop the current recording + // (POST /recording/stop) + StopRecording(ctx context.Context, request StopRecordingRequestObject) (StopRecordingResponseObject, error) +} + +type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc +type StrictMiddlewareFunc = strictnethttp.StrictHTTPMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// DownloadRecording operation middleware +func (sh *strictHandler) DownloadRecording(w http.ResponseWriter, r *http.Request) { + var request DownloadRecordingRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DownloadRecording(ctx, request.(DownloadRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DownloadRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DownloadRecordingResponseObject); ok { + if err := validResponse.VisitDownloadRecordingResponse(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)) + } +} + +// StartRecording operation middleware +func (sh *strictHandler) StartRecording(w http.ResponseWriter, r *http.Request) { + var request StartRecordingRequestObject + + var body StartRecordingJSONRequestBody + 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.StartRecording(ctx, request.(StartRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StartRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StartRecordingResponseObject); ok { + if err := validResponse.VisitStartRecordingResponse(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)) + } +} + +// StopRecording operation middleware +func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { + var request StopRecordingRequestObject + + var body StopRecordingJSONRequestBody + 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.StopRecording(ctx, request.(StopRecordingRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StopRecording") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StopRecordingResponseObject); ok { + if err := validResponse.VisitStopRecordingResponse(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)) + } +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/7xVUW/bNhD+KwduDxsgWMqaDZjelm0FjCFd0ext2AMjnmwWJI89nty6hf77QCqWo9hr", + "gy3Lm0we7+77vrvPn1RHPlLAIEm1nxRjihQSlh9X2rzBdwMm+ZWZOB91FASD5E8do7OdFkuhfpso5LPU", + "bdHr/PU1Y69a9VV9zF9Pt6meso3jWCmDqWMbcxLV5oJwV1GNlfqZQu9s91zVD+Vy6XUQ5KDdM5U+lIMb", + "5B0y3AVW6hXJSxqCeaY+XpFAqafy3V14zjbXj0wRWew0IR5T0hvMn7KPqFqVhG3YlOeM7wbLaFT75xz4", + "V3UIpNu3OHF9I5rlDXbExobNQf8M0BibG9Pu9b2qvXYJqweN9Kw9spbSyhLTnBnmILAB+pjgG9ohszWY", + "IE3EG+z14ORbVSmvP1g/eNX+0FTK2zD9uJgB2CC4waKS1x9eWoc39iOuw/XVaQ/XUy7orUNI9mPp4Prq", + "kQ1cNE2z6KE5bWI8SyzF/8orcYc5z4Sp9DaHPphh79FYLej2kIQivLeypUFgw7rDfnCQtoMYeh9W8MfW", + "JvB6D4xpcJLZ0NAR8xAFDeysQSpkrdSM65bIoQ7noOYjG3oqc2jF5bvfkAM6WHu9wQQ/vV6rSu2Q09Rs", + "s7pYNZkjihh0tKpVL1bN6oWqVNSyLdhrPnBX564daZOPN1hIzCyV3Vsb1apf7gJmulW19NLvmubB+haQ", + "tY+Xy73tib2WjNcGzfsj/nmzTrb23oRbhxnV5VTtnAfMXdUP7b28u/zyu6UnjZX6/jHVlo5a7GXwPiM8", + "sgeyRfCUBBg7DOLygGRsi5koj++Jk7J/FG+idEaapb2oyZcwyRWZ/ZM56nkPG8fJBxdzcPE5iypY0Exa", + "/PhlVpd/kU+hRUECGlLHiAFmmlfwe3B7oIDHM+h0gFsE3YndIej8TqzH1alEk4P8k0L3fOp/E+iMF57V", + "p/m8PhTjQZ9/t2NPoBDFsindwIxBjnoUQH8HAAD///UXgf/TCQAA", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/server/lib/recorder/ffmeg_test.go b/server/lib/recorder/ffmeg_test.go new file mode 100644 index 00000000..47e3a03e --- /dev/null +++ b/server/lib/recorder/ffmeg_test.go @@ -0,0 +1,59 @@ +package recorder + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + mockBin = filepath.Join("testdata", "mock_ffmpeg.sh") +) + +func defaultParams(tempDir string) FFmpegRecordingParams { + fr := 5 + disp := 0 + size := 1 + return FFmpegRecordingParams{ + FrameRate: &fr, + DisplayNum: &disp, + MaxSizeInMB: &size, + OutputDir: &tempDir, + } +} + +func TestFFmpegRecorder_StartAndStop(t *testing.T) { + rec := &FFmpegRecorder{ + id: "startstop", + binaryPath: mockBin, + params: defaultParams(t.TempDir()), + } + require.NoError(t, rec.Start(t.Context())) + require.True(t, rec.IsRecording(t.Context())) + + time.Sleep(50 * time.Millisecond) + + require.NoError(t, rec.Stop(t.Context())) + <-rec.exited + require.False(t, rec.IsRecording(t.Context())) +} + +func TestFFmpegRecorder_ForceStop(t *testing.T) { + rec := &FFmpegRecorder{ + id: "startstop", + binaryPath: mockBin, + params: defaultParams(t.TempDir()), + } + require.NoError(t, rec.Start(t.Context())) + require.True(t, rec.IsRecording(t.Context())) + + time.Sleep(50 * time.Millisecond) + + require.NoError(t, rec.ForceStop(t.Context())) + <-rec.exited + require.False(t, rec.IsRecording(t.Context())) + assert.Contains(t, rec.cmd.ProcessState.String(), "killed") +} diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go new file mode 100644 index 00000000..bf7e5e92 --- /dev/null +++ b/server/lib/recorder/ffmpeg.go @@ -0,0 +1,439 @@ +package recorder + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/onkernel/kernel-images/server/lib/logger" +) + +const ( + // arbitrary value to indicate we have not yet received an exit code from the process + exitCodeInitValue = math.MinInt + + // the exit codes returned by the stdlib: + // -1 if the process hasn't exited yet or was terminated by a signal + // 0 if the process exited successfully + // >0 if the process exited with a non-zero exit code + exitCodeProcessDoneMinValue = -1 +) + +// FFmpegRecorder encapsulates an FFmpeg recording session with platform-specific screen capture. +// It manages the lifecycle of a single FFmpeg process and provides thread-safe operations. +type FFmpegRecorder struct { + mu sync.Mutex + + id string + binaryPath string // path to the ffmpeg binary to execute. Defaults to "ffmpeg". + cmd *exec.Cmd + params FFmpegRecordingParams + outputPath string + startTime time.Time + endTime time.Time + ffmpegErr error + exitCode int + exited chan struct{} +} + +type FFmpegRecordingParams struct { + FrameRate *int + DisplayNum *int + MaxSizeInMB *int + OutputDir *string +} + +func (p FFmpegRecordingParams) Validate() error { + if p.OutputDir == nil { + return fmt.Errorf("output directory is required") + } + if p.FrameRate == nil { + return fmt.Errorf("frame rate is required") + } + if p.DisplayNum == nil { + return fmt.Errorf("display number is required") + } + if p.MaxSizeInMB == nil { + return fmt.Errorf("max size in MB is required") + } + + return nil +} + +type FFmpegRecorderFactory func(id string, overrides FFmpegRecordingParams) (Recorder, error) + +// NewFFmpegRecorderFactory returns a factory that creates new recorders. The provided +// pathToFFmpeg is used as the binary to execute; if empty it defaults to "ffmpeg" which +// is expected to be discoverable on the host's PATH. +func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams) FFmpegRecorderFactory { + return func(id string, overrides FFmpegRecordingParams) (Recorder, error) { + mergedParams := mergeFFmpegRecordingParams(config, overrides) + return &FFmpegRecorder{ + id: id, + binaryPath: pathToFFmpeg, + outputPath: filepath.Join(*mergedParams.OutputDir, fmt.Sprintf("%s.mp4", id)), + params: mergedParams, + }, nil + } +} + +func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRecordingParams) FFmpegRecordingParams { + merged := FFmpegRecordingParams{ + FrameRate: config.FrameRate, + DisplayNum: config.DisplayNum, + MaxSizeInMB: config.MaxSizeInMB, + OutputDir: config.OutputDir, + } + if overrides.FrameRate != nil { + merged.FrameRate = overrides.FrameRate + } + if overrides.DisplayNum != nil { + merged.DisplayNum = overrides.DisplayNum + } + if overrides.MaxSizeInMB != nil { + merged.MaxSizeInMB = overrides.MaxSizeInMB + } + if overrides.OutputDir != nil { + merged.OutputDir = overrides.OutputDir + } + + return merged +} + +// ID returns the unique identifier for this recorder. +func (fr *FFmpegRecorder) ID() string { + return fr.id +} + +// Start begins the recording process by launching ffmpeg with the configured parameters. +func (fr *FFmpegRecorder) Start(ctx context.Context) error { + log := logger.FromContext(ctx) + + fr.mu.Lock() + if fr.cmd != nil { + return fmt.Errorf("recording already in progress") + } + + // ensure internal state + fr.ffmpegErr = nil + fr.exitCode = exitCodeInitValue + fr.startTime = time.Now() + fr.exited = make(chan struct{}) + + args, err := ffmpegArgs(fr.params, fr.outputPath) + if err != nil { + return err + } + log.Info(fmt.Sprintf("%s %s", fr.binaryPath, strings.Join(args, " "))) + + cmd := exec.Command(fr.binaryPath, args...) + // create process group to ensure all processes are signaled together + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + fr.cmd = cmd + fr.mu.Unlock() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start ffmpeg process: %w", err) + } + + // Launch background waiter to capture process completion. + go fr.waitForCommand(ctx) + + // Check for startup errors before returning + if err := waitForChan(ctx, 500*time.Millisecond, fr.exited); err == nil { + fr.mu.Lock() + defer fr.mu.Unlock() + return fmt.Errorf("failed to start ffmpeg process: %w", fr.ffmpegErr) + } + + return nil +} + +// Stop gracefully stops the recording using a multi-phase shutdown process. +func (fr *FFmpegRecorder) Stop(ctx context.Context) error { + return fr.shutdownInPhases(ctx, []shutdownPhase{ + {"interrupt", []syscall.Signal{syscall.SIGCONT, syscall.SIGINT}, 5 * time.Second, "graceful stop"}, + {"terminate", []syscall.Signal{syscall.SIGTERM}, 2 * time.Second, "forceful termination"}, + {"kill", []syscall.Signal{syscall.SIGKILL}, 1 * time.Second, "immediate kill"}, + }) +} + +// ForceStop immediately terminates the recording process. +func (fr *FFmpegRecorder) ForceStop(ctx context.Context) error { + return fr.shutdownInPhases(ctx, []shutdownPhase{ + {"kill", []syscall.Signal{syscall.SIGKILL}, 1 * time.Second, "immediate kill"}, + }) +} + +// IsRecording returns true if a recording is currently in progress. +func (fr *FFmpegRecorder) IsRecording(ctx context.Context) bool { + fr.mu.Lock() + defer fr.mu.Unlock() + return fr.cmd != nil && fr.exitCode < exitCodeProcessDoneMinValue +} + +// Recording returns the recording file as an io.ReadCloser. +func (fr *FFmpegRecorder) Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) { + if fr.IsRecording(ctx) { + return nil, nil, fmt.Errorf("recording still in progress, please call stop first") + } + + file, err := os.Open(fr.outputPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to open recording file: %w", err) + } + + finfo, err := file.Stat() + if err != nil { + // Ensure the file descriptor is not leaked on error + file.Close() + return nil, nil, fmt.Errorf("failed to get recording file info: %w", err) + } + + fr.mu.Lock() + defer fr.mu.Unlock() + return file, &RecordingMetadata{ + Size: finfo.Size(), + StartTime: fr.startTime, + EndTime: fr.endTime, + }, nil +} + +// ffmpegArgs generates platform-specific ffmpeg command line arguments. +func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { + switch runtime.GOOS { + case "darwin": + return []string{ + // Input configuration - Use AVFoundation for macOS screen capture + "-f", "avfoundation", + "-framerate", strconv.Itoa(*params.FrameRate), + "-pixel_format", "nv12", + "-i", fmt.Sprintf("%d:none", *params.DisplayNum), // Screen capture, no audio + + // Video encoding + "-c:v", "libx264", + + // Timestamp handling for reliable playback + "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time + "-reset_timestamps", "1", // Reset timestamps to start from zero + "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero + + // Error handling + "-xerror", // Exit on any error + + // Output configuration for data safety + "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety + "-frag_duration", "2000000", // 2-second fragments (in microseconds) + "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit + "-y", // Overwrite output file if it exists + outputPath, + }, nil + case "linux": + return []string{ + // Input configuration - Use X11 screen capture for Linux + "-f", "x11grab", + "-framerate", strconv.Itoa(*params.FrameRate), + "-i", fmt.Sprintf(":%d", *params.DisplayNum), // X11 display + + // Video encoding + "-c:v", "libx264", + + // Timestamp handling for reliable playback + "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time + "-reset_timestamps", "1", // Reset timestamps to start from zero + "-avoid_negative_ts", "make_zero", // Convert negative timestamps to zero + + // Error handling + "-xerror", // Exit on any error + + // Output configuration for data safety + "-movflags", "+frag_keyframe+empty_moov", // Enable fragmented MP4 for data safety + "-frag_duration", "2000000", // 2-second fragments (in microseconds) + "-fs", fmt.Sprintf("%dM", *params.MaxSizeInMB), // File size limit + "-y", // Overwrite output file if it exists + outputPath, + }, nil + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} + +// waitForCommand should be run in the background to wait for the ffmpeg process to complete and +// update the internal state accordingly. +func (fr *FFmpegRecorder) waitForCommand(ctx context.Context) { + log := logger.FromContext(ctx) + + // wait for the process to complete and extract the exit code + err := fr.cmd.Wait() + + // update internal state and cleanup + fr.mu.Lock() + defer fr.mu.Unlock() + fr.ffmpegErr = err + fr.exitCode = fr.cmd.ProcessState.ExitCode() + fr.endTime = time.Now() + close(fr.exited) + + if err != nil { + log.Info("ffmpeg process completed with error", "err", err, "exitCode", fr.exitCode) + } else { + log.Info("ffmpeg process completed successfully", "exitCode", fr.exitCode) + } +} + +type shutdownPhase struct { + name string + signals []syscall.Signal + timeout time.Duration + desc string +} + +func (fr *FFmpegRecorder) shutdownInPhases(ctx context.Context, phases []shutdownPhase) error { + log := logger.FromContext(ctx) + + // capture immutable references under lock + fr.mu.Lock() + exitCode := fr.exitCode + cmd := fr.cmd + done := fr.exited + fr.mu.Unlock() + + if exitCode >= exitCodeProcessDoneMinValue { + log.Info("ffmpeg process has already exited") + return nil + } + if cmd == nil || cmd.Process == nil { + return fmt.Errorf("no recording to stop") + } + + pgid := -cmd.Process.Pid // negative PGID targets the whole group + for _, phase := range phases { + // short circuit: the process exited before this phase started. + select { + case <-done: + return nil + default: + } + + log.Info("ffmpeg shutdown phase", "phase", phase.name, "desc", phase.desc) + + // Send the phase's signals in order. + for _, sig := range phase.signals { + _ = syscall.Kill(pgid, sig) // ignore error; process may have gone away + } + + // Wait for exit or timeout + if err := waitForChan(ctx, phase.timeout, done); err == nil { + log.Info("ffmpeg shutdown successful", "phase", phase.name) + return nil + } + } + + return fmt.Errorf("failed to shutdown ffmpeg") +} + +// waitForChan returns nil if and only if the channel is closed +func waitForChan(ctx context.Context, timeout time.Duration, c <-chan struct{}) error { + select { + case <-c: + return nil + case <-time.After(timeout): + return fmt.Errorf("process did not exit within %v timeout", timeout) + case <-ctx.Done(): + return ctx.Err() + } +} + +type FFmpegManager struct { + mu sync.Mutex + recorders map[string]Recorder +} + +func NewFFmpegManager() *FFmpegManager { + return &FFmpegManager{ + recorders: make(map[string]Recorder), + } +} + +func (fm *FFmpegManager) GetRecorder(id string) (Recorder, bool) { + fm.mu.Lock() + defer fm.mu.Unlock() + + recorder, exists := fm.recorders[id] + return recorder, exists +} + +func (fm *FFmpegManager) ListActiveRecorders(ctx context.Context) []string { + fm.mu.Lock() + defer fm.mu.Unlock() + + var active []string + for id, recorder := range fm.recorders { + if recorder.IsRecording(ctx) { + active = append(active, id) + } + } + return active +} + +func (fm *FFmpegManager) DeregisterRecorder(ctx context.Context, recorder Recorder) error { + fm.mu.Lock() + defer fm.mu.Unlock() + + delete(fm.recorders, recorder.ID()) + return nil +} + +func (fm *FFmpegManager) RegisterRecorder(ctx context.Context, recorder Recorder) error { + log := logger.FromContext(ctx) + + fm.mu.Lock() + defer fm.mu.Unlock() + + // Check for existing recorder with same ID + if _, exists := fm.recorders[recorder.ID()]; exists { + return fmt.Errorf("recorder with id '%s' already exists", recorder.ID()) + } + + fm.recorders[recorder.ID()] = recorder + log.Info("registered new recorder", "id", recorder.ID()) + return nil +} + +func (fm *FFmpegManager) StopAll(ctx context.Context) error { + log := logger.FromContext(ctx) + + fm.mu.Lock() + defer fm.mu.Unlock() + + var errs []error + for id, recorder := range fm.recorders { + if recorder.IsRecording(ctx) { + if err := recorder.Stop(ctx); err != nil { + errs = append(errs, fmt.Errorf("failed to stop recorder '%s': %w", id, err)) + log.Error("failed to stop recorder during shutdown", "id", id, "err", err) + } + } + } + + log.Info("stopped all recorders", "count", len(fm.recorders)) + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} diff --git a/server/lib/recorder/recorder.go b/server/lib/recorder/recorder.go new file mode 100644 index 00000000..cbcc9140 --- /dev/null +++ b/server/lib/recorder/recorder.go @@ -0,0 +1,44 @@ +package recorder + +import ( + "context" + "io" + "time" +) + +// Recorder defines the interface for recording functionality. +type Recorder interface { + ID() string + Start(ctx context.Context) error + Stop(ctx context.Context) error + ForceStop(ctx context.Context) error + IsRecording(ctx context.Context) bool + Recording(ctx context.Context) (io.ReadCloser, *RecordingMetadata, error) // Returns the recording file as a ReadCloser +} + +type RecordingMetadata struct { + Size int64 + StartTime time.Time + EndTime time.Time +} + +// RecordManager defines the interface for managing multiple recorder instances. +// Implementations should be thread-safe for concurrent access. +type RecordManager interface { + // GetRecorder retrieves a recorder by its ID. + // Returns the recorder and true if found, nil and false otherwise. + GetRecorder(id string) (Recorder, bool) + + // ListActiveRecorders returns a list of IDs for all currently recording recorders. + ListActiveRecorders(ctx context.Context) []string + + // DeregisterRecorder removes a recorder from the manager. + DeregisterRecorder(ctx context.Context, recorder Recorder) error + + // RegisterRecorder registers a recorder with the given ID. + // Returns an error if a recorder with the same ID already exists. + RegisterRecorder(ctx context.Context, recorder Recorder) error + + // StopAll stops all active recorders. + StopAll(ctx context.Context) error +} diff --git a/server/lib/recorder/testdata/mock_ffmpeg.sh b/server/lib/recorder/testdata/mock_ffmpeg.sh new file mode 100755 index 00000000..2369780b --- /dev/null +++ b/server/lib/recorder/testdata/mock_ffmpeg.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail +echo "$@" + +sleep_pid="" + +cleanup_and_exit() { + # Force-kill the background sleep instantly (signal 9) if it exists. + if [[ -n "$sleep_pid" ]]; then + kill -9 "$sleep_pid" 2>/dev/null || true + fi + exit "${MOCK_FFMPEG_EXIT_CODE:-101}" +} + +# Gracefully stop when recorder sends SIGINT or SIGTERM. +trap cleanup_and_exit INT TERM + +# Keep the process alive until a signal is delivered. Store PID for cleanup. +sleep "${MOCK_FFMPEG_SLEEP_SECONDS:-600}" & +sleep_pid=$! + +# Wait on the background job (it will already be gone after SIGKILL). +wait "$sleep_pid" 2>/dev/null || true diff --git a/server/oapi-codegen.yaml b/server/oapi-codegen.yaml new file mode 100644 index 00000000..ae65d3a3 --- /dev/null +++ b/server/oapi-codegen.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: oapi +generate: + strict-server: true + client: true + models: true + embedded-spec: true + chi-server: true +output: lib/oapi/oapi.go diff --git a/server/openapi.go b/server/openapi.go new file mode 100644 index 00000000..e8ed6155 --- /dev/null +++ b/server/openapi.go @@ -0,0 +1,6 @@ +package server + +import _ "embed" + +//go:embed openapi.yaml +var OpenAPIYAML []byte diff --git a/server/openapi.yaml b/server/openapi.yaml new file mode 100644 index 00000000..8d4979c9 --- /dev/null +++ b/server/openapi.yaml @@ -0,0 +1,113 @@ +openapi: 3.1.0 +info: + title: Kernel Images API + version: 0.1.0 +paths: + /recording/start: + post: + summary: Start a screen recording. Only one recording can be active at a time. + operationId: startRecording + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StartRecordingRequest" + responses: + "201": + description: Recording started + "409": + description: A recording is already in progress + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + /recording/stop: + post: + summary: Stop the current recording + operationId: stopRecording + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StopRecordingRequest" + responses: + "200": + description: Recording stopped + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" + /recording/download: + get: + summary: Download the most recently recorded video file + operationId: downloadRecording + responses: + "200": + description: Recording file + content: + video/mp4: + schema: + type: string + format: binary + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" +components: + schemas: + StartRecordingRequest: + type: object + properties: + maxFileSizeInMB: + type: integer + description: Maximum file size in MB (overrides server default) + minimum: 10 + maximum: 10000 + framerate: + type: integer + description: Recording framerate in fps (overrides server default) + minimum: 1 + maximum: 60 + additionalProperties: false + StopRecordingRequest: + type: object + properties: + forceStop: + type: boolean + description: Immediately stop without graceful shutdown. This may result in a corrupted video file. + default: false + additionalProperties: false + Error: + type: object + required: [message] + properties: + message: + type: string + responses: + BadRequestError: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + ConflictError: + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFoundError: + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalError: + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error"